Build a Local AI Packet Analysis CLI

Build a local Python CLI that captures, ranks, and explains packet metadata.

Introduction

30 Second Summary

Your Mac exchanges data in the background every time you browse a website or open an app. When something looks unusual, the hardest part is finding the few details worth checking inside all that activity.

In this project, you will build PacketSage, a Python CLI that turns a bounded packet capture of authorized traffic into a ranked evidence report. A local Ollama model explains that metadata while Wireshark keeps the original packets available for direct validation.

What You'll Build

Your PacketSage demo turns a short capture from a network interface into a ranked report whose evidence you can verify in Wireshark.

By the end of this project, you'll have:

  • A one-command interface list that shows the names Scapy can use on your Mac. Your bounded capture stops after a packet count or duration.
  • A standard PCAP file named capture.pcap that opens in Wireshark. You can compare its packets with the terminal summaries from the same sample.
  • A deterministic traffic report that narrows a noisy sample to five packet records worth checking. A local AI explanation that turns those records into observations, plausible explanations, and next checks.
  • Secret Mission: Trace one ranked packet in Wireshark. Audit one AI claim against the packet's direct evidence.

Are There Any Prerequisites?

You need basic Python knowledge plus basic networking knowledge on a Mac running Python 3.8+.

The project also requires permission to inspect the chosen interface plus enough disk space for the local model and capture files.

Before We Start

This checkpoint commits you to a bounded local packet capture of traffic you own or are authorized to inspect. PacketSage uses metadata-only analysis backed by primary evidence in Wireshark.

Set Up the Local Analysis Environment

PacketSage needs a predictable local environment before it can capture trustworthy evidence. Your VS Code project folder keeps the environment files together.

A local Ollama model keeps each explanation on your Mac. Wireshark provides the packet-capture permission that PacketSage needs.

In this step, get ready to:
  • Create an isolated Python environment.
  • Install the pinned analysis packages and local model.
  • Confirm macOS packet-capture permission.
Prepare the Python environment

The installed Python version determines whether both pinned packages can run. PacketSage requires Python 3.8 or later.

  • Switch to the integrated terminal in your VS Code project folder.
  • Check the installed Python version by running this command:
python3 --version

What does this command show?

The command prints the Python interpreter version available to this terminal. A version of 3.8 or later supports both pinned packages.

✔️ I see version 3.8 or higher

Your Python interpreter meets PacketSage's minimum requirement. You can use it to create the isolated environment.

ⓧ I see an older version

Your current interpreter is below the required version. Install a current Python release before creating the environment.

  • Visit the official Python releases for macOS page.
  • Download a current macOS installer.
  • Run the downloaded installer.
  • Close the current integrated terminal.
  • Start a new integrated terminal in your VS Code project folder.
  • Repeat the Python version check shown above.

ⓧ The command is not found

Your terminal cannot find a Python 3 installation. The official macOS installer adds a current interpreter to your Mac.

  • Visit the official Python releases for macOS page.
  • Download a current macOS installer.
  • Run the downloaded installer.
  • Close the current integrated terminal.
  • Start a new integrated terminal in your VS Code project folder.
  • Repeat the Python version check shown above.

A virtual environment gives PacketSage its own package directory. This prevents its pinned dependencies from changing other Python projects.

  • Create the .venv environment in your VS Code project folder by running this command:
python3 -m venv .venv

What does this command create?

The command creates a .venv directory inside your project folder. That directory contains an isolated Python interpreter.

  • Activate the new environment by running this command:
source .venv/bin/activate

What should change?

Your terminal prompt now includes .venv. New package installations stay inside this project environment.

The environment did not activate?

Confirm that the integrated terminal is using the same project folder that contains .venv.

Check that the activation path uses the exact .venv directory name. Help me troubleshoot my Python virtual environment.

Install the local analysis stack

PacketSage uses Scapy for packet capture and PCAP files. The ollama Python client connects the CLI to the local model.

  • Create requirements.txt inside the open project folder using the new-file control in the VS Code Explorer.
  • Add the pinned dependencies by copying this content into the file:
scapy==2.7.0
ollama==0.6.2

Why pin these packages?

  • The scapy==2.7.0 pin provides the packet-capture and PCAP features used by PacketSage.
  • The ollama==0.6.2 pin provides the Python client used for local explanations.
  • Save requirements.txt.
  • Install both packages into the active environment by running this command:
python3 -m pip install -r requirements.txt

What does this command install?

The command reads both version pins from requirements.txt. The active .venv receives the packet-analysis library and local model client.

Good progress. Your terminal returns to the prompt without an installation error when both packages are ready.

Package installation failed?

Confirm that your terminal prompt still includes .venv. Check that both dependency lines match the pinned versions exactly.

Use the non-sensitive terminal output to troubleshoot the dependency installation.

✔️ Awesome, I've got everything!

Your saved dependency file contains both required pins. Your active environment now has PacketSage's Python packages.

ⓧ I'd like to double check the full code

scapy==2.7.0
ollama==0.6.2

What should match?

Your complete requirements.txt file contains these two lines in this order. No other dependencies belong in this file.

The Ollama application must be installed before its Python client can contact a local model. The gemma3:1b model requires Ollama 0.6 or later.

  • Choose the tab that matches the Ollama installation currently on your Mac.

✔️ I have Ollama 0.6 or later

Your existing Ollama installation supports the local model used by PacketSage. Keep the application available for the final model check.

ⓧ I have an older Ollama version

Updating Ollama changes an application in your Mac's Applications folder. The official installer also links the terminal command for you.

  • Upgrade Ollama by running the official installer command:
curl -fsSL https://ollama.com/install.sh | sh

What does this installer change?

The installer places Ollama.app in /Applications/. It also links the Ollama CLI for terminal use.

The installer prints progress while it updates the application. Its completion message confirms that Ollama is ready to run.

The Ollama upgrade failed?

Confirm that your Mac has an internet connection. Check whether macOS requested administrator approval.

Use the non-sensitive installer output to troubleshoot the Ollama upgrade.

ⓧ Ollama is not installed

Installing Ollama adds an application to your Mac's Applications folder. The official installer also makes its terminal command available.

  • Install Ollama by running the official installer command:
curl -fsSL https://ollama.com/install.sh | sh

What does this installer add?

The installer places Ollama.app in /Applications/. It also links the Ollama CLI for terminal use.

The installer prints progress while it downloads the application. Its completion message confirms that Ollama is ready to run.

The Ollama installation failed?

Confirm that your Mac has an internet connection. Check whether macOS requested administrator approval.

Use the non-sensitive installer output to troubleshoot the Ollama installation.

Confirm capture permission and test the model

macOS protects its packet-capture devices. The ChmodBPF launch daemon gives Wireshark-compatible access to authorized traffic.

  • Check whether the ChmodBPF launch daemon exists by running this command:
ls -l /Library/LaunchDaemons/org.wireshark.ChmodBPF.plist

What does this file prove?

The ChmodBPF installer creates /Library/LaunchDaemons/org.wireshark.ChmodBPF.plist. A listed file confirms that the packet-capture permission service is installed.

✔️ The ChmodBPF file is listed

Your Mac has the Wireshark packet-capture launch daemon. PacketSage can use this permission for authorized captures.

ⓧ The ChmodBPF file is missing

The ChmodBPF installer changes a system permission service. macOS asks for administrator approval before applying the change.

  • Press Cmd+Space to open Spotlight.
  • Type Wireshark into Spotlight.
  • Press Enter to open Wireshark.
  • Select Wireshark in the macOS menu bar.
  • Select About Wireshark.
  • Select the Folders tab.
  • Double-click macOS Extras.

Where does this lead?

The macOS Extras folder contains Wireshark's packet-capture permission installer. This is the supported path for adding ChmodBPF.

  • Open Install ChmodBPF.pkg.
  • Complete the macOS installer.
  • Confirm the ChmodBPF file now exists by running this command:
ls -l /Library/LaunchDaemons/org.wireshark.ChmodBPF.plist

What should you see?

The output lists /Library/LaunchDaemons/org.wireshark.ChmodBPF.plist. This confirms that the packet-capture permission service is installed.

The ChmodBPF file is still missing?

Confirm that the package installer reached its completion screen. Check whether macOS rejected the administrator approval.

Use the installer result to troubleshoot the missing ChmodBPF launch daemon.

The first model run downloads gemma3:1b before opening an interactive prompt. Keep the terminal open while the download progress moves.

Before you run the final check, do you expect the model to answer locally once its download finishes?

  • Start the local model by running this command:
ollama run gemma3:1b

What does this command prove?

Ollama downloads the model when needed. The interactive prompt confirms that the application can run gemma3:1b locally.

  • Enter Reply with PacketSage ready at the model prompt.

You will see a locally generated response acknowledging the readiness request. The exact wording can vary.

The local model did not respond?

Confirm that Ollama is running on your Mac. Leave the terminal open if the model download is still progressing.

Use the non-sensitive terminal output to troubleshoot the local model response.

Your isolated environment can now capture packets and contact a local model. Next, you will build the command that discovers the network interfaces available to PacketSage.

Build the Interface Discovery Command

Your local model is responding. Your packet-capture permissions are also ready.

A hard-coded interface can silently point PacketSage at the wrong device. This step uses Scapy to discover the interface names exposed by your Mac before any capture begins.

In this step, get ready to:
  • Create the PacketSage file with interface discovery logic.
  • Connect the interfaces subcommand to the CLI.
  • Match Scapy's interface names with Wireshark.
Discover Scapy-visible interfaces

The full import group gives PacketSage the dependencies used across the finished file. This first slice uses get_if_list() to return the interface names Scapy can see.

  • Create packetsage.py inside the current project folder with the New File control in VS Code's Explorer sidebar.
  • Confirm packetsage.py is listed in the Explorer sidebar.
  • Populate packetsage.py by pasting this code:
import argparse
from collections import Counter
from pathlib import Path

from ollama import chat
from scapy.interfaces import get_if_list
from scapy.layers.inet import ICMP, IP, TCP, UDP
from scapy.layers.inet6 import IPv6
from scapy.sendrecv import sniff
from scapy.utils import rdpcap, wrpcap

DEFAULT_MODEL = "gemma3:1b"


def list_interfaces():
    names = get_if_list()
    if not names:
        print("No interfaces found.")
        return

    print("Available interfaces:")
    for name in names:
        print(f"- {name}")

What does this code do?

  • The import group exposes the tools that PacketSage uses for interface lookup plus later packet processing.
  • The DEFAULT_MODEL value records the local model identifier used later in the project.
  • The get_if_list() call asks Scapy for the interface names available on this Mac.
  • The empty-list check prints No interfaces found. when Scapy returns no names.
  • The final loop prints every discovered name on its own line.
  • Save packetsage.py.
  • Check that the file starts with import argparse.
  • Check that the function ends with print(f"- {name}").

Are the imports underlined?

Confirm that VS Code is using the activated .venv environment from the previous step. Scapy plus the Ollama client were installed into that environment.

Help me check why VS Code cannot resolve the PacketSage imports.

Wire the interfaces subcommand

An argparse subcommand maps a terminal word to one program behavior. PacketSage uses the interfaces word to call list_interfaces().

  • Place your cursor below the list_interfaces() function in packetsage.py.
  • Add the parser plus command dispatch by pasting this code:
def build_parser():
    parser = argparse.ArgumentParser(
        prog="PacketSage",
        description="Capture bounded traffic and explain ranked metadata locally.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)
    subparsers.add_parser("interfaces", help="List available interfaces")
    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()

    try:
        if args.command == "interfaces":
            list_interfaces()
    except KeyboardInterrupt:
        parser.exit(130, "Interrupted.\n")
    except Exception as error:
        parser.exit(1, f"Error: {error}\n")


if __name__ == "__main__":
    main()

How does the command flow work?

  • The build_parser() function creates the PacketSage command parser.
  • The required subparser makes the learner choose a command.
  • The interfaces subcommand represents the discovery behavior.
  • The main() function reads the selected command.
  • The dispatch check calls list_interfaces() for the interface discovery path.
  • The exception handlers turn interruptions plus runtime failures into concise terminal messages.
  • Save packetsage.py.

Before you run the new command, how many interface names do you expect this Mac to expose?

  • Run the interface discovery command in the activated terminal:
python packetsage.py interfaces

What should you see?

The output starts with Available interfaces:. One or more dash-prefixed interface names follow it.

You have your first working PacketSage command. The CLI can now ask this Mac which interfaces Scapy can inspect.

No interface names listed?

Confirm that the terminal still shows the active .venv environment. A result containing No interfaces found. means Scapy returned an empty interface list.

Help me diagnose why PacketSage cannot list my Mac network interfaces.

Match the active interface in Wireshark

Matching the two interface lists protects the next capture from targeting the wrong device. Your choice must belong to traffic you own or are authorized to inspect.

  • Keep the PacketSage interface output visible in the terminal.
  • Switch back to Wireshark from the previous step.
  • Compare Wireshark's interface list with the names printed by PacketSage.
  • Identify one matching interface that shows current activity on your authorized network.
  • Record the authorized active interface here: <interface-name>.

You should find the recorded interface name in both lists. That match confirms PacketSage can address the same interface that Wireshark can inspect.

✔️ Awesome, I've got everything!

Your interfaces command lists Scapy-visible names. You also have an authorized active interface ready for the bounded capture.

ⓧ I'd like to double check the full code

import argparse
from collections import Counter
from pathlib import Path

from ollama import chat
from scapy.interfaces import get_if_list
from scapy.layers.inet import ICMP, IP, TCP, UDP
from scapy.layers.inet6 import IPv6
from scapy.sendrecv import sniff
from scapy.utils import rdpcap, wrpcap

DEFAULT_MODEL = "gemma3:1b"


def list_interfaces():
    names = get_if_list()
    if not names:
        print("No interfaces found.")
        return

    print("Available interfaces:")
    for name in names:
        print(f"- {name}")


def build_parser():
    parser = argparse.ArgumentParser(
        prog="PacketSage",
        description="Capture bounded traffic and explain ranked metadata locally.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)
    subparsers.add_parser("interfaces", help="List available interfaces")
    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()

    try:
        if args.command == "interfaces":
            list_interfaces()
    except KeyboardInterrupt:
        parser.exit(130, "Interrupted.\n")
    except Exception as error:
        parser.exit(1, f"Error: {error}\n")


if __name__ == "__main__":
    main()

PacketSage can now discover the correct capture target. Next, you'll use that interface to collect a bounded packet sample plus save it as a PCAP file.

Capture a Bounded Packet Sample

PacketSage already lists the network interfaces that Scapy can see. You also selected an active interface that you are authorized to inspect.

Raw packet summaries provide evidence one line at a time. A strict stopping condition keeps the sample controlled while you experience that output directly.

In this step, get ready to:
  • Add a bounded packet capture function.
  • Expose mutually exclusive count or duration limits through the capture command.
  • Validate the saved PCAP in Wireshark.
Add the bounded capture function

A bounded capture stops after a packet count or a duration. This prevents PacketSage from collecting traffic indefinitely.

  • Switch back to packetsage.py in VS Code from earlier.
  • Locate the existing def build_parser(): line.
  • Insert the capture function directly above that line by pasting this code:
def capture_packets(interface, output, count=None, seconds=None):
    if count is not None and count <= 0:
        raise ValueError("--count must be greater than zero")
    if seconds is not None and seconds <= 0:
        raise ValueError("--seconds must be greater than zero")

    print(f"Capturing on {interface}...")
    packets = sniff(
        iface=interface,
        count=count or 0,
        timeout=seconds,
        prn=lambda packet: print(packet.summary()),
        store=True,
    )

    if not packets:
        print("No packets captured. No PCAP was written.")
        return

    wrpcap(output, packets)
    size = Path(output).stat().st_size
    print(f"Saved {len(packets)} packets to {output} ({size} bytes).")

What does this code do?

  • The validation rejects non-positive values for count or seconds.
  • The iface value tells sniff() which network interface to inspect.
  • The count value stops a packet-count capture.
  • The timeout value stops a duration-based capture.
  • The prn callback prints each packet summary as traffic arrives.
  • The store=True setting retains the captured packets for the PCAP file.
  • The wrpcap() call writes a non-empty sample to disk.
  • The Path check reports the saved file size.
  • Save packetsage.py.
  • Confirm the existing interface command still runs by using this command:
python packetsage.py interfaces

What does this check prove?

You'll see the interface names from the previous step. This confirms the new function has not broken the existing command.

Seeing a Python syntax problem?

Check that capture_packets() begins at the left edge of the file. Confirm every line inside the function uses four spaces of indentation.

Compare the closing parentheses around sniff() with the code block above.

Help me fix my capture function.

Wire the capture command

Python's argparse library turns terminal options into values for capture_packets(). PacketSage also needs a dispatch branch that calls the function when the learner chooses the capture command.

  • Locate the existing main() function near the bottom of packetsage.py.
  • Replace the entire function with this version:
def main():
    parser = build_parser()
    args = parser.parse_args()

    try:
        if args.command == "interfaces":
            list_interfaces()
        elif args.command == "capture":
            capture_packets(
                interface=args.interface,
                output=args.output,
                count=args.count,
                seconds=args.seconds,
            )
    except KeyboardInterrupt:
        parser.exit(130, "Interrupted.\n")
    except Exception as error:
        parser.exit(1, f"Error: {error}\n")

How does dispatch work?

The new branch recognizes the capture command. It passes the parsed interface and limit values into capture_packets().

The existing exception handling keeps keyboard interruptions separate from other failures.

  • Save packetsage.py.
  • Confirm the unchanged interface branch still works by running:
python packetsage.py interfaces

What should remain working?

You'll see one or more interface names again. The new dispatch branch leaves the earlier command intact.

Did the interface command stop working?

Check that the first branch still calls list_interfaces(). Confirm the new branch begins with elif at the same indentation level.

Help me repair the command dispatch.

  • Locate the existing build_parser() function.
  • Replace the entire function with this version:
def build_parser():
    parser = argparse.ArgumentParser(
        prog="PacketSage",
        description="Capture bounded traffic and explain ranked metadata locally.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    subparsers.add_parser("interfaces", help="List available interfaces")

    capture_parser = subparsers.add_parser(
        "capture",
        help="Capture packets to a PCAP file",
    )
    capture_parser.add_argument("--interface", required=True)
    limit_group = capture_parser.add_mutually_exclusive_group(required=True)
    limit_group.add_argument("--count", type=int)
    limit_group.add_argument("--seconds", type=float)
    capture_parser.add_argument("--output", default="capture.pcap")

    return parser

What do the capture options control?

  • The capture subcommand separates packet collection from interface discovery.
  • The required --interface option identifies the authorized interface.
  • The mutually exclusive group requires exactly one capture limit.
  • The --count option limits the number of packets.
  • The --seconds option limits the duration.
  • The --output option defaults to capture.pcap.
  • Save packetsage.py.
Capture and inspect the sample

This run uses a packet-count limit of 20. The terminal returns to your prompt as soon as the twentieth packet arrives.

Keep the sample authorized

A PCAP can contain addresses, ports, timing information, and other sensitive network metadata. The strict bound reduces how much diagnostic data the file contains.

  • Record the authorized active interface from the previous step here: <interface-name>.
  • Limit this capture to traffic you own or are authorized to inspect.
  • Choose an ordinary authorized web page to refresh during the capture.
  • Refresh that page after PacketSage starts listening.

Before you run the capture, do you think PacketSage stops by itself after the twentieth packet?

  • Start the bounded capture by running this command:
python packetsage.py capture --interface [[INTERFACE_NAME="<interface-name>"]] --count 20 --output capture.pcap

What should happen?

You'll first see a line beginning with Capturing on. Each captured packet then produces one summary line.

After the twentieth packet, you'll see a saved line containing capture.pcap plus a non-zero byte count. The process returns to your terminal prompt on its own.

The 20 summary lines expose traffic in arrival order. They provide no priorities or reasons.

Even this small sample can feel noisy. That limitation is the evidence problem PacketSage tackles next.

That is the capture loop working: PacketSage stopped itself at 20 packets.

No packets or PCAP file?

  • Confirm the interface value matches the active name you identified earlier.
  • Generate ordinary traffic on the authorized interface while the capture is listening.
  • Return to the ChmodBPF setup from Step 1 if macOS blocks packet access.
  • Confirm the terminal reports that capture.pcap was saved before looking for the file.

Help me diagnose my bounded capture.

  • Return to Wireshark from earlier.
  • Use Wireshark's file chooser to open capture.pcap from the same folder as packetsage.py.
  • Confirm the packet list contains the same 20-frame sample.
  • Redact source addresses before sharing a screenshot.
  • Redact destination addresses before sharing a screenshot.

You'll see 20 frames with protocol and length details. This proves the terminal summaries and saved PCAP represent the same bounded capture.

✔️ Awesome, I've got everything!

Your bounded sample is saved as capture.pcap. Double-check that you saved packetsage.py.

ⓧ I'd like to double check the full code

  • Compare your saved packetsage.py with this complete reference:
import argparse
from collections import Counter
from pathlib import Path

from ollama import chat
from scapy.interfaces import get_if_list
from scapy.layers.inet import ICMP, IP, TCP, UDP
from scapy.layers.inet6 import IPv6
from scapy.sendrecv import sniff
from scapy.utils import rdpcap, wrpcap

DEFAULT_MODEL = "gemma3:1b"


def list_interfaces():
    names = get_if_list()
    if not names:
        print("No interfaces found.")
        return

    print("Available interfaces:")
    for name in names:
        print(f"- {name}")


def capture_packets(interface, output, count=None, seconds=None):
    if count is not None and count <= 0:
        raise ValueError("--count must be greater than zero")
    if seconds is not None and seconds <= 0:
        raise ValueError("--seconds must be greater than zero")

    print(f"Capturing on {interface}...")
    packets = sniff(
        iface=interface,
        count=count or 0,
        timeout=seconds,
        prn=lambda packet: print(packet.summary()),
        store=True,
    )

    if not packets:
        print("No packets captured. No PCAP was written.")
        return

    wrpcap(output, packets)
    size = Path(output).stat().st_size
    print(f"Saved {len(packets)} packets to {output} ({size} bytes).")


def build_parser():
    parser = argparse.ArgumentParser(
        prog="PacketSage",
        description="Capture bounded traffic and explain ranked metadata locally.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    subparsers.add_parser("interfaces", help="List available interfaces")

    capture_parser = subparsers.add_parser(
        "capture",
        help="Capture packets to a PCAP file",
    )
    capture_parser.add_argument("--interface", required=True)
    limit_group = capture_parser.add_mutually_exclusive_group(required=True)
    limit_group.add_argument("--count", type=int)
    limit_group.add_argument("--seconds", type=float)
    capture_parser.add_argument("--output", default="capture.pcap")

    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()

    try:
        if args.command == "interfaces":
            list_interfaces()
        elif args.command == "capture":
            capture_packets(
                interface=args.interface,
                output=args.output,
                count=args.count,
                seconds=args.seconds,
            )
    except KeyboardInterrupt:
        parser.exit(130, "Interrupted.\n")
    except Exception as error:
        parser.exit(1, f"Error: {error}\n")


if __name__ == "__main__":
    main()

What should match?

  • The capture_packets() function appears between list_interfaces() and build_parser().
  • The build_parser() function defines the required capture options.
  • The main() function routes the capture command to capture_packets().

Your reusable packet sample is ready. Next, you'll turn its unprioritized summaries into a ranked evidence report.

Rank Evidence with Explainable Heuristics

Your bounded capture now gives PacketSage a reusable capture.pcap sample. Its raw summaries leave every packet competing for your attention.

This step turns packet metadata into a deterministic ranking. Every score keeps its reason visible so you can inspect the evidence before trusting an explanation.

In this step, get ready to:
  • Convert each packet into a consistent metadata record.
  • Rank sample-relative evidence with visible scoring reasons.
  • Print a deterministic report with totals and up to five ranked records.
Convert packets into metadata records

A consistent record lets the later scoring rules compare packets field by field. PacketSage extracts addresses and transport details without reading packet payloads.

  • In packetsage.py, find the blank space between capture_packets() and build_parser().
  • Start packet_record() in that space by adding this code:
def packet_record(index, packet):
    source = "unknown"
    destination = "unknown"

    if IP in packet:
        source = packet[IP].src
        destination = packet[IP].dst
    elif IPv6 in packet:
        source = packet[IPv6].src
        destination = packet[IPv6].dst

    protocol = "OTHER"
    source_port = None
    destination_port = None
    flags = ""

How are the defaults chosen?

  • The source and destination fields start as unknown so packets without an IP layer still have complete records.
  • The IP and IPv6 checks extract addresses from either network-layer format.
  • The transport fields begin empty so the next section can fill only the details present in each packet.
  • Leave one blank line below flags = "".
  • Complete packet_record() by adding this code inside the function:
    if TCP in packet:
        protocol = "TCP"
        source_port = int(packet[TCP].sport)
        destination_port = int(packet[TCP].dport)
        flags = str(packet[TCP].flags)
    elif UDP in packet:
        protocol = "UDP"
        source_port = int(packet[UDP].sport)
        destination_port = int(packet[UDP].dport)
    elif ICMP in packet:
        protocol = "ICMP"

    return {
        "index": index,
        "protocol": protocol,
        "source": source,
        "destination": destination,
        "source_port": source_port,
        "destination_port": destination_port,
        "flags": flags,
        "length": len(packet),
    }

What does this code record?

  • The transport checks classify each packet as TCP, UDP, ICMP, or OTHER.
  • TCP and UDP records include source and destination ports.
  • TCP records also include flags that can reveal a reset.
  • The returned dictionary keeps the original packet number and captured length for later ranking.
  • Save packetsage.py.

Seeing an indentation error?

Confirm that both transport checks and the returned dictionary stay indented inside packet_record(). Keep def packet_record(index, packet): aligned with the other function definitions.

Ask for help checking the function structure without changing its fields.

Score sample-relative evidence

A high score marks metadata that differs from this bounded sample. Each reason stays attached to its packet so the ranking remains inspectable.

  • Add analyze_packets() below packet_record() by starting with this code:
def analyze_packets(packets):
    records = [
        packet_record(index, packet)
        for index, packet in enumerate(packets, start=1)
    ]
    protocol_counts = Counter(record["protocol"] for record in records)
    destination_counts = Counter(
        record["destination"]
        for record in records
        if record["destination"] != "unknown"
    )
    port_counts = Counter(
        record["destination_port"]
        for record in records
        if record["destination_port"] is not None
    )
    average_size = sum(record["length"] for record in records) / len(records)

How does the sample become comparable?

  • The list comprehension converts every captured packet through packet_record().
  • Each Counter measures how often a protocol or destination value occurs in this sample.
  • The average_size value creates a sample-specific baseline for packet length.
  • Leave one blank line below the average_size calculation.
  • Add the rarity rules inside analyze_packets() with this code:
    for record in records:
        score = 0
        reasons = []

        if len(records) >= 5 and protocol_counts[record["protocol"]] == 1:
            score += 2
            reasons.append("protocol appears once in this sample")

        destination = record["destination"]
        if (
            len(records) >= 5
            and destination != "unknown"
            and destination_counts[destination] == 1
        ):
            score += 1
            reasons.append("destination appears once in this sample")

        destination_port = record["destination_port"]
        if (
            len(records) >= 5
            and destination_port is not None
            and port_counts[destination_port] == 1
        ):
            score += 2
            reasons.append("destination port appears once in this sample")

Why measure rarity within the sample?

The rules compare each packet with the traffic captured beside it. A protocol or destination port that appears once receives more attention because it differs from the local sample.

The minimum sample size prevents tiny captures from treating every value as meaningful rarity. Scores identify inspection leads while direct packet evidence supports security conclusions.

  • Continue the same loop below the destination-port rule by adding this code:
        if record["length"] > average_size * 2:
            score += 2
            reasons.append("packet is more than twice the sample average size")

        if record["protocol"] == "TCP" and "R" in record["flags"]:
            score += 2
            reasons.append("TCP reset flag is present")

        record["score"] = score
        record["reasons"] = reasons or ["included as comparison context"]

    ranked = sorted(
        records,
        key=lambda record: (record["score"], record["length"]),
        reverse=True,
    )[:5]

    return {
        "records": records,
        "protocol_counts": protocol_counts,
        "destination_counts": destination_counts,
        "average_size": average_size,
        "ranked": ranked,
    }

How is the final ranking built?

  • A packet earns points when its length exceeds twice the sample average.
  • A TCP reset earns points because it can mark a connection ending abruptly.
  • Packets without a scoring reason remain available as comparison context.
  • The sort prioritizes score first and packet length second before keeping the top five records.
  • Save packetsage.py.

Seeing a missing key or division error?

Check that every field referenced by analyze_packets() appears in the dictionary returned by packet_record(). The later PCAP loader rejects empty captures before this function calculates the average.

Ask for help comparing the record fields with the scoring rules.

Print and verify the deterministic report

The terminal report keeps the evidence layer readable before local AI interpretation is added. Totals establish context while ranked rows preserve the exact reasons behind each score.

  • Add print_report() below analyze_packets() by starting with this code:
def print_report(analysis):
    records = analysis["records"]
    print("\nTraffic summary")
    print(f"Packets: {len(records)}")
    print(f"Average packet length: {analysis['average_size']:.1f} bytes")

    print("Protocols:")
    for protocol, count in analysis["protocol_counts"].most_common():
        print(f"- {protocol}: {count}")

    print("Common destinations:")
    destinations = analysis["destination_counts"].most_common(5)
    if destinations:
        for destination, count in destinations:
            print(f"- {destination}: {count}")
    else:
        print("- No IP destinations found")

What does the summary establish?

  • The packet total confirms how many records were analyzed.
  • The average length provides the baseline used by the size heuristic.
  • The protocol and destination counts show the wider sample around each ranked packet.
  • Leave one blank line below the destination fallback.
  • Complete print_report() by adding the ranked-record output:
    print("Ranked packet metadata:")
    for record in analysis["ranked"]:
        ports = ""
        if record["destination_port"] is not None:
            ports = (
                f" ports={record['source_port']}"
                f"->{record['destination_port']}"
            )
        flags = f" flags={record['flags']}" if record["flags"] else ""
        reasons = "; ".join(record["reasons"])
        print(
            f"- packet={record['index']} score={record['score']} "
            f"protocol={record['protocol']} "
            f"source={record['source']} "
            f"destination={record['destination']} "
            f"length={record['length']}{ports}{flags} "
            f"reasons={reasons}"
        )

Why keep each reason on the row?

Each ranked row links its score to the exact protocol, addresses, ports, flags, length, and reasons used by the heuristics. You can challenge any ranking without relying on generated prose.

  • Add analyze_pcap() below print_report() with this code:
def analyze_pcap(filename, model):
    path = Path(filename)
    if not path.is_file():
        raise FileNotFoundError(f"PCAP not found: {filename}")

    packets = rdpcap(str(path))
    if not packets:
        raise ValueError("The PCAP contains no packets")

    analysis = analyze_packets(packets)
    print_report(analysis)

What does the PCAP loader protect?

  • The file check gives a clear failure when the requested PCAP path is missing.
  • The empty-capture check stops the average calculation from receiving an empty packet list.
  • The final two lines keep analysis and presentation separate.
  • Find the existing build_parser() function.
  • Replace that function with this updated version:
def build_parser():
    parser = argparse.ArgumentParser(
        prog="PacketSage",
        description="Capture bounded traffic and explain ranked metadata locally.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    subparsers.add_parser("interfaces", help="List available interfaces")

    capture_parser = subparsers.add_parser(
        "capture",
        help="Capture packets to a PCAP file",
    )
    capture_parser.add_argument("--interface", required=True)
    limit_group = capture_parser.add_mutually_exclusive_group(required=True)
    limit_group.add_argument("--count", type=int)
    limit_group.add_argument("--seconds", type=float)
    capture_parser.add_argument("--output", default="capture.pcap")

    analyze_parser = subparsers.add_parser(
        "analyze",
        help="Analyze a PCAP with deterministic evidence and local AI",
    )
    analyze_parser.add_argument("pcap")
    analyze_parser.add_argument("--model", default=DEFAULT_MODEL)

    return parser

What does the new subcommand accept?

The analyze subcommand requires a PCAP path. Its --model option already defaults to DEFAULT_MODEL so the next step can add local interpretation without changing the command shape.

  • Find the existing main() function.
  • Replace that function with this updated version:
def main():
    parser = build_parser()
    args = parser.parse_args()

    try:
        if args.command == "interfaces":
            list_interfaces()
        elif args.command == "capture":
            capture_packets(
                interface=args.interface,
                output=args.output,
                count=args.count,
                seconds=args.seconds,
            )
        elif args.command == "analyze":
            analyze_pcap(args.pcap, args.model)
    except KeyboardInterrupt:
        parser.exit(130, "Interrupted.\n")
    except Exception as error:
        parser.exit(1, f"Error: {error}\n")

How does the command reach the report?

The new branch passes the requested PCAP path and model value to analyze_pcap(). The model value is carried forward for the next step while this version prints deterministic evidence only.

  • Save packetsage.py.

Before you run the analysis, which protocols or destination patterns do you expect PacketSage to rank from your bounded sample?

  • Generate the deterministic report by running this command:
python packetsage.py analyze capture.pcap

What should you see?

You will see a Traffic summary with the packet total and average length. The report then shows protocol counts and common destinations.

The final section lists up to five ranked packet records. Each row includes its score and visible reasons.

That is the noisy capture distilled into an evidence list you can inspect. The report ends with ranked metadata so the next step can add interpretation on top of a complete deterministic result.

Report missing or failing?

Confirm that capture.pcap remains beside packetsage.py. Check that the function names and dictionary keys match the code above exactly.

Ask for help tracing the failure from the CLI branch through the PCAP loader and report functions.

✔️ Awesome, I've got everything!

Your deterministic report now summarizes the capture and ranks up to five inspectable packet records. Keep capture.pcap available for the local explanation step.

ⓧ I'd like to double check the full code

import argparse
from collections import Counter
from pathlib import Path

from ollama import chat
from scapy.interfaces import get_if_list
from scapy.layers.inet import ICMP, IP, TCP, UDP
from scapy.layers.inet6 import IPv6
from scapy.sendrecv import sniff
from scapy.utils import rdpcap, wrpcap

DEFAULT_MODEL = "gemma3:1b"


def list_interfaces():
    names = get_if_list()
    if not names:
        print("No interfaces found.")
        return

    print("Available interfaces:")
    for name in names:
        print(f"- {name}")


def capture_packets(interface, output, count=None, seconds=None):
    if count is not None and count <= 0:
        raise ValueError("--count must be greater than zero")
    if seconds is not None and seconds <= 0:
        raise ValueError("--seconds must be greater than zero")

    print(f"Capturing on {interface}...")
    packets = sniff(
        iface=interface,
        count=count or 0,
        timeout=seconds,
        prn=lambda packet: print(packet.summary()),
        store=True,
    )

    if not packets:
        print("No packets captured. No PCAP was written.")
        return

    wrpcap(output, packets)
    size = Path(output).stat().st_size
    print(f"Saved {len(packets)} packets to {output} ({size} bytes).")


def packet_record(index, packet):
    source = "unknown"
    destination = "unknown"

    if IP in packet:
        source = packet[IP].src
        destination = packet[IP].dst
    elif IPv6 in packet:
        source = packet[IPv6].src
        destination = packet[IPv6].dst

    protocol = "OTHER"
    source_port = None
    destination_port = None
    flags = ""

    if TCP in packet:
        protocol = "TCP"
        source_port = int(packet[TCP].sport)
        destination_port = int(packet[TCP].dport)
        flags = str(packet[TCP].flags)
    elif UDP in packet:
        protocol = "UDP"
        source_port = int(packet[UDP].sport)
        destination_port = int(packet[UDP].dport)
    elif ICMP in packet:
        protocol = "ICMP"

    return {
        "index": index,
        "protocol": protocol,
        "source": source,
        "destination": destination,
        "source_port": source_port,
        "destination_port": destination_port,
        "flags": flags,
        "length": len(packet),
    }


def analyze_packets(packets):
    records = [
        packet_record(index, packet)
        for index, packet in enumerate(packets, start=1)
    ]
    protocol_counts = Counter(record["protocol"] for record in records)
    destination_counts = Counter(
        record["destination"]
        for record in records
        if record["destination"] != "unknown"
    )
    port_counts = Counter(
        record["destination_port"]
        for record in records
        if record["destination_port"] is not None
    )
    average_size = sum(record["length"] for record in records) / len(records)

    for record in records:
        score = 0
        reasons = []

        if len(records) >= 5 and protocol_counts[record["protocol"]] == 1:
            score += 2
            reasons.append("protocol appears once in this sample")

        destination = record["destination"]
        if (
            len(records) >= 5
            and destination != "unknown"
            and destination_counts[destination] == 1
        ):
            score += 1
            reasons.append("destination appears once in this sample")

        destination_port = record["destination_port"]
        if (
            len(records) >= 5
            and destination_port is not None
            and port_counts[destination_port] == 1
        ):
            score += 2
            reasons.append("destination port appears once in this sample")

        if record["length"] > average_size * 2:
            score += 2
            reasons.append("packet is more than twice the sample average size")

        if record["protocol"] == "TCP" and "R" in record["flags"]:
            score += 2
            reasons.append("TCP reset flag is present")

        record["score"] = score
        record["reasons"] = reasons or ["included as comparison context"]

    ranked = sorted(
        records,
        key=lambda record: (record["score"], record["length"]),
        reverse=True,
    )[:5]

    return {
        "records": records,
        "protocol_counts": protocol_counts,
        "destination_counts": destination_counts,
        "average_size": average_size,
        "ranked": ranked,
    }


def print_report(analysis):
    records = analysis["records"]
    print("\nTraffic summary")
    print(f"Packets: {len(records)}")
    print(f"Average packet length: {analysis['average_size']:.1f} bytes")

    print("Protocols:")
    for protocol, count in analysis["protocol_counts"].most_common():
        print(f"- {protocol}: {count}")

    print("Common destinations:")
    destinations = analysis["destination_counts"].most_common(5)
    if destinations:
        for destination, count in destinations:
            print(f"- {destination}: {count}")
    else:
        print("- No IP destinations found")

    print("Ranked packet metadata:")
    for record in analysis["ranked"]:
        ports = ""
        if record["destination_port"] is not None:
            ports = (
                f" ports={record['source_port']}"
                f"->{record['destination_port']}"
            )
        flags = f" flags={record['flags']}" if record["flags"] else ""
        reasons = "; ".join(record["reasons"])
        print(
            f"- packet={record['index']} score={record['score']} "
            f"protocol={record['protocol']} "
            f"source={record['source']} "
            f"destination={record['destination']} "
            f"length={record['length']}{ports}{flags} "
            f"reasons={reasons}"
        )


def analyze_pcap(filename, model):
    path = Path(filename)
    if not path.is_file():
        raise FileNotFoundError(f"PCAP not found: {filename}")

    packets = rdpcap(str(path))
    if not packets:
        raise ValueError("The PCAP contains no packets")

    analysis = analyze_packets(packets)
    print_report(analysis)


def build_parser():
    parser = argparse.ArgumentParser(
        prog="PacketSage",
        description="Capture bounded traffic and explain ranked metadata locally.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    subparsers.add_parser("interfaces", help="List available interfaces")

    capture_parser = subparsers.add_parser(
        "capture",
        help="Capture packets to a PCAP file",
    )
    capture_parser.add_argument("--interface", required=True)
    limit_group = capture_parser.add_mutually_exclusive_group(required=True)
    limit_group.add_argument("--count", type=int)
    limit_group.add_argument("--seconds", type=float)
    capture_parser.add_argument("--output", default="capture.pcap")

    analyze_parser = subparsers.add_parser(
        "analyze",
        help="Analyze a PCAP with deterministic evidence and local AI",
    )
    analyze_parser.add_argument("pcap")
    analyze_parser.add_argument("--model", default=DEFAULT_MODEL)

    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()

    try:
        if args.command == "interfaces":
            list_interfaces()
        elif args.command == "capture":
            capture_packets(
                interface=args.interface,
                output=args.output,
                count=args.count,
                seconds=args.seconds,
            )
        elif args.command == "analyze":
            analyze_pcap(args.pcap, args.model)
    except KeyboardInterrupt:
        parser.exit(130, "Interrupted.\n")
    except Exception as error:
        parser.exit(1, f"Error: {error}\n")


if __name__ == "__main__":
    main()

Your evidence pipeline now turns a noisy capture into a compact ranking with inspectable reasons. Next, you will ask the local model to explain that evidence in cautious language.

Add the Local AI Explanation

Your deterministic report already turns capture.pcap into ranked evidence. The remaining gap is accessibility. Scores and flags can still feel technical.

A local Ollama model translates the ranked metadata into cautious language. Packet payloads stay outside the prompt.

In this step, get ready to:
  • Build a prompt from protocol totals and ranked metadata.
  • Request a local explanation with a safe fallback.
  • Compare every AI statement with the deterministic evidence.
Build the metadata-only evidence prompt

An evidence prompt gives the model the same protocol totals and ranked records you can inspect. This keeps the explanation tied to explicit scores and reasons.

  • In packetsage.py, find the end of print_report().
  • Add the first section of explain_with_ollama() two blank lines below it by pasting this code:
def explain_with_ollama(analysis, model):
    protocol_text = ", ".join(
        f"{protocol}={count}"
        for protocol, count in analysis["protocol_counts"].most_common()
    )
    evidence_lines = []
    for record in analysis["ranked"]:
        evidence_lines.append(
            f"Packet {record['index']}: score={record['score']}, "
            f"protocol={record['protocol']}, source={record['source']}, "
            f"destination={record['destination']}, "
            f"source_port={record['source_port']}, "
            f"destination_port={record['destination_port']}, "
            f"flags={record['flags'] or 'none'}, "
            f"length={record['length']}, "
            f"reasons={'; '.join(record['reasons'])}"
        )

What does this code prepare?

  • The protocol_text value turns protocol counts into a compact summary for the model.
  • The evidence_lines list preserves each ranked packet number.
  • Each evidence line includes the score and its visible reasons.
  • The selected fields describe packet metadata without adding payload bytes.
  • Save packetsage.py.
  • Confirm the existing analysis path still runs by using this command:
python packetsage.py analyze capture.pcap

What should remain stable?

You should still see the traffic summary and ranked packet metadata. This confirms the new evidence-building function has not disrupted the deterministic report.

Does the script stop?

Check that every line inside explain_with_ollama() keeps four spaces of indentation. Confirm each formatted string remains inside the evidence_lines.append( call.

Help me check the indentation in my new evidence-building function.

Request a cautious local explanation

The second half sets interpretation boundaries before the local request. It also catches service failures so the deterministic report remains useful.

  • In explain_with_ollama(), find evidence_lines.append(.
  • Paste this indented block one blank line after the call's closing parenthesis:
    prompt = (
        "Explain this bounded packet-capture evidence for a beginner. "
        "Separate your response into Observations, Plausible explanations, "
        "and Next verification steps. Do not claim malware, intrusion, or "
        "compromise from these heuristics. No packet payload was inspected.\n\n"
        f"Protocol totals: {protocol_text}\n"
        f"Average packet length: {analysis['average_size']:.1f} bytes\n"
        "Ranked metadata:\n"
        + "\n".join(evidence_lines)
    )

    print("\nAI explanation")
    try:
        response = chat(
            model=model,
            messages=[{"role": "user", "content": prompt}],
        )
        print(response.message.content)
    except Exception as error:
        print(f"Ollama explanation unavailable: {error}")
        print("The deterministic report above is still complete.")

How does the explanation stay cautious?

  • The prompt requests separate observations and plausible explanations.
  • The prompt asks for next verification steps.
  • The prompt states that unusual heuristics do not prove a compromise.
  • The chat() call sends the prompt to the selected local model.
  • The exception handler preserves the completed deterministic report if the local request fails.
  • Save packetsage.py.
  • Confirm the completed function parses by running the deterministic analysis again:
python packetsage.py analyze capture.pcap

What does this check prove?

You should see the deterministic traffic summary and ranked records again. This proves Python can load the completed function before you connect it to the analysis flow.

Does the report fail to load?

Check that the prompt assignment stays inside explain_with_ollama(). Confirm the try and except lines align with each other.

Help me inspect the completed Ollama explanation function.

The deterministic report must appear before generated prose. That order lets you inspect the evidence before reading the model's interpretation.

  • In packetsage.py, find these lines at the end of analyze_pcap():
    analysis = analyze_packets(packets)
    print_report(analysis)

What is the current flow?

The current function reads the capture and builds the analysis. It finishes by printing the deterministic report.

  • Add the explanation call directly below print_report(analysis) so the lines look like this:
    analysis = analyze_packets(packets)
    print_report(analysis)
    explain_with_ollama(analysis, model)

Why does the order matter?

The deterministic evidence now prints before explain_with_ollama() runs. A model failure cannot erase the evidence that your Python analysis already produced.

✔️ Awesome, I've got everything!

Your explanation function and analysis flow are complete. Save packetsage.py before the final run.

ⓧ I'd like to double check the full code

import argparse
from collections import Counter
from pathlib import Path

from ollama import chat
from scapy.interfaces import get_if_list
from scapy.layers.inet import ICMP, IP, TCP, UDP
from scapy.layers.inet6 import IPv6
from scapy.sendrecv import sniff
from scapy.utils import rdpcap, wrpcap

DEFAULT_MODEL = "gemma3:1b"


def list_interfaces():
    names = get_if_list()
    if not names:
        print("No interfaces found.")
        return

    print("Available interfaces:")
    for name in names:
        print(f"- {name}")


def capture_packets(interface, output, count=None, seconds=None):
    if count is not None and count <= 0:
        raise ValueError("--count must be greater than zero")
    if seconds is not None and seconds <= 0:
        raise ValueError("--seconds must be greater than zero")

    print(f"Capturing on {interface}...")
    packets = sniff(
        iface=interface,
        count=count or 0,
        timeout=seconds,
        prn=lambda packet: print(packet.summary()),
        store=True,
    )

    if not packets:
        print("No packets captured. No PCAP was written.")
        return

    wrpcap(output, packets)
    size = Path(output).stat().st_size
    print(f"Saved {len(packets)} packets to {output} ({size} bytes).")


def packet_record(index, packet):
    source = "unknown"
    destination = "unknown"

    if IP in packet:
        source = packet[IP].src
        destination = packet[IP].dst
    elif IPv6 in packet:
        source = packet[IPv6].src
        destination = packet[IPv6].dst

    protocol = "OTHER"
    source_port = None
    destination_port = None
    flags = ""

    if TCP in packet:
        protocol = "TCP"
        source_port = int(packet[TCP].sport)
        destination_port = int(packet[TCP].dport)
        flags = str(packet[TCP].flags)
    elif UDP in packet:
        protocol = "UDP"
        source_port = int(packet[UDP].sport)
        destination_port = int(packet[UDP].dport)
    elif ICMP in packet:
        protocol = "ICMP"

    return {
        "index": index,
        "protocol": protocol,
        "source": source,
        "destination": destination,
        "source_port": source_port,
        "destination_port": destination_port,
        "flags": flags,
        "length": len(packet),
    }


def analyze_packets(packets):
    records = [
        packet_record(index, packet)
        for index, packet in enumerate(packets, start=1)
    ]
    protocol_counts = Counter(record["protocol"] for record in records)
    destination_counts = Counter(
        record["destination"]
        for record in records
        if record["destination"] != "unknown"
    )
    port_counts = Counter(
        record["destination_port"]
        for record in records
        if record["destination_port"] is not None
    )
    average_size = sum(record["length"] for record in records) / len(records)

    for record in records:
        score = 0
        reasons = []

        if len(records) >= 5 and protocol_counts[record["protocol"]] == 1:
            score += 2
            reasons.append("protocol appears once in this sample")

        destination = record["destination"]
        if (
            len(records) >= 5
            and destination != "unknown"
            and destination_counts[destination] == 1
        ):
            score += 1
            reasons.append("destination appears once in this sample")

        destination_port = record["destination_port"]
        if (
            len(records) >= 5
            and destination_port is not None
            and port_counts[destination_port] == 1
        ):
            score += 2
            reasons.append("destination port appears once in this sample")

        if record["length"] > average_size * 2:
            score += 2
            reasons.append("packet is more than twice the sample average size")

        if record["protocol"] == "TCP" and "R" in record["flags"]:
            score += 2
            reasons.append("TCP reset flag is present")

        record["score"] = score
        record["reasons"] = reasons or ["included as comparison context"]

    ranked = sorted(
        records,
        key=lambda record: (record["score"], record["length"]),
        reverse=True,
    )[:5]

    return {
        "records": records,
        "protocol_counts": protocol_counts,
        "destination_counts": destination_counts,
        "average_size": average_size,
        "ranked": ranked,
    }


def print_report(analysis):
    records = analysis["records"]
    print("\nTraffic summary")
    print(f"Packets: {len(records)}")
    print(f"Average packet length: {analysis['average_size']:.1f} bytes")

    print("Protocols:")
    for protocol, count in analysis["protocol_counts"].most_common():
        print(f"- {protocol}: {count}")

    print("Common destinations:")
    destinations = analysis["destination_counts"].most_common(5)
    if destinations:
        for destination, count in destinations:
            print(f"- {destination}: {count}")
    else:
        print("- No IP destinations found")

    print("Ranked packet metadata:")
    for record in analysis["ranked"]:
        ports = ""
        if record["destination_port"] is not None:
            ports = (
                f" ports={record['source_port']}"
                f"->{record['destination_port']}"
            )
        flags = f" flags={record['flags']}" if record["flags"] else ""
        reasons = "; ".join(record["reasons"])
        print(
            f"- packet={record['index']} score={record['score']} "
            f"protocol={record['protocol']} "
            f"source={record['source']} "
            f"destination={record['destination']} "
            f"length={record['length']}{ports}{flags} "
            f"reasons={reasons}"
        )


def explain_with_ollama(analysis, model):
    protocol_text = ", ".join(
        f"{protocol}={count}"
        for protocol, count in analysis["protocol_counts"].most_common()
    )
    evidence_lines = []
    for record in analysis["ranked"]:
        evidence_lines.append(
            f"Packet {record['index']}: score={record['score']}, "
            f"protocol={record['protocol']}, source={record['source']}, "
            f"destination={record['destination']}, "
            f"source_port={record['source_port']}, "
            f"destination_port={record['destination_port']}, "
            f"flags={record['flags'] or 'none'}, "
            f"length={record['length']}, "
            f"reasons={'; '.join(record['reasons'])}"
        )

    prompt = (
        "Explain this bounded packet-capture evidence for a beginner. "
        "Separate your response into Observations, Plausible explanations, "
        "and Next verification steps. Do not claim malware, intrusion, or "
        "compromise from these heuristics. No packet payload was inspected.\n\n"
        f"Protocol totals: {protocol_text}\n"
        f"Average packet length: {analysis['average_size']:.1f} bytes\n"
        "Ranked metadata:\n"
        + "\n".join(evidence_lines)
    )

    print("\nAI explanation")
    try:
        response = chat(
            model=model,
            messages=[{"role": "user", "content": prompt}],
        )
        print(response.message.content)
    except Exception as error:
        print(f"Ollama explanation unavailable: {error}")
        print("The deterministic report above is still complete.")


def analyze_pcap(filename, model):
    path = Path(filename)
    if not path.is_file():
        raise FileNotFoundError(f"PCAP not found: {filename}")

    packets = rdpcap(str(path))
    if not packets:
        raise ValueError("The PCAP contains no packets")

    analysis = analyze_packets(packets)
    print_report(analysis)
    explain_with_ollama(analysis, model)


def build_parser():
    parser = argparse.ArgumentParser(
        prog="PacketSage",
        description="Capture bounded traffic and explain ranked metadata locally.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    subparsers.add_parser("interfaces", help="List available interfaces")

    capture_parser = subparsers.add_parser(
        "capture",
        help="Capture packets to a PCAP file",
    )
    capture_parser.add_argument("--interface", required=True)
    limit_group = capture_parser.add_mutually_exclusive_group(required=True)
    limit_group.add_argument("--count", type=int)
    limit_group.add_argument("--seconds", type=float)
    capture_parser.add_argument("--output", default="capture.pcap")

    analyze_parser = subparsers.add_parser(
        "analyze",
        help="Analyze a PCAP with deterministic evidence and local AI",
    )
    analyze_parser.add_argument("pcap")
    analyze_parser.add_argument("--model", default=DEFAULT_MODEL)

    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()

    try:
        if args.command == "interfaces":
            list_interfaces()
        elif args.command == "capture":
            capture_packets(
                interface=args.interface,
                output=args.output,
                count=args.count,
                seconds=args.seconds,
            )
        elif args.command == "analyze":
            analyze_pcap(args.pcap, args.model)
    except KeyboardInterrupt:
        parser.exit(130, "Interrupted.\n")
    except Exception as error:
        parser.exit(1, f"Error: {error}\n")


if __name__ == "__main__":
    main()

How to use this reference

Compare each function boundary and indentation level with your saved file. The deterministic report call must remain directly above the local explanation call.

Verify the explanation against the evidence

The final run creates the full evidence ladder. PacketSage prints deterministic facts first and generated interpretation second.

  • Save packetsage.py.

Before you run the final check, which deterministic details do you expect the local explanation to reference?

  • Run the complete local analysis by using this command:
python packetsage.py analyze capture.pcap --model gemma3:1b

What should you see?

You should see the deterministic traffic summary and ranked packet metadata first. An AI explanation section should follow with observations and plausible explanations.

The response should also include next verification steps. Its claims should stay within the displayed protocol totals and ranked metadata.

  • Compare each AI observation with the protocol totals and ranked records above it.
  • Match each plausible explanation to one or more displayed reasons.
  • Flag any statement that extends beyond the displayed metadata as requiring further verification.

Only seeing the deterministic report?

If the local request fails, PacketSage prints Ollama explanation unavailable: while preserving the report.

  • Confirm the local Ollama service from Step 1 is still running.
  • Confirm gemma3:1b remains available locally.

Help me restore the local explanation without changing the evidence report.

You now have the full evidence ladder. PacketSage ranks inspectable metadata before asking a local model to explain it.

Secret mission

Challenge the AI with Wireshark Evidence

Audit one PacketSage claim against the matching packet in Wireshark. Record whether direct evidence confirms, corrects, or leaves the claim inconclusive.

Clean Up Your Resources

Clean Up Your Resources

Everything in PacketSage runs locally, so there are no ongoing cloud charges. Your options are to keep the project available, pause its local processes, or delete its data.

Resources you used:

  • The completed packetsage.py source file.
  • The requirements.txt dependency file.
  • A .venv containing Scapy 2.7.0 plus the ollama Python client 0.6.2.
  • The local gemma3:1b model managed by Ollama.
  • The authorized PCAP file named capture.pcap that may contain sensitive network metadata.
  • The three-line analyst note based on direct Wireshark evidence.

Keep everything running

No action is needed. Choose this if you want PacketSage ready for more authorized captures or demonstrations.

  • Retain packetsage.py with requirements.txt for repeatable setup.
  • Keep .venv for future local runs.
  • Retain gemma3:1b for later explanations.
  • Protect capture.pcap like other diagnostic data.
  • Preserve the analyst note with your validated findings.

Pause - I'll come back to this later

Stop the local processes to free memory while preserving the project. Your files plus the downloaded model remain available for later use.

  • Leave the activated .venv in your current terminal by running this command:
deactivate

What does this command do?

The deactivate command removes .venv from the current shell session. The environment files stay on disk.

  • Confirm the (.venv) prefix is gone from the terminal prompt.
  • Quit the running Ollama service from the Ollama macOS app.
  • Retain capture.pcap only in a location appropriate for sensitive diagnostic data.

Delete - I don't want to use this again

Deleting the project is permanent. Removing capture.pcap first limits how long its network metadata remains on your Mac.

  • Leave the activated .venv in your current terminal by running this command:
deactivate

What does this command do?

The deactivate command detaches the current terminal from .venv. It prepares the environment folder for removal.

  • Locate the project folder containing packetsage.py in Finder.
  • Delete capture.pcap from that folder first.
  • Delete the project folder from Finder.
  • Delete the three-line analyst note from its saved location if it was stored outside the project folder.
  • Remove the local gemma3:1b model through Ollama.
  • Confirm Ollama no longer shows gemma3:1b among its local models.
  • Quit the Ollama macOS app.

Nice Work!

Nice Work!

You did it! PacketSage now turns authorized packet captures into ranked evidence with a cautious local explanation.

What you learned:

  • Built a bounded capture CLI that discovers Scapy-visible interfaces. Captured authorized traffic within a packet count or duration limit.
  • Saved a reusable PCAP file. Turned its packet metadata into protocol totals, common destinations, and ranked records with visible reasons.
  • Added a fully local Ollama explanation based on ranked metadata. Kept packet payloads outside the model prompt.
  • Secret Mission: Audited one AI claim against the matching packet in Wireshark. Marked the claim as confirmed, corrected, or inconclusive using direct evidence.

Ready to quiz yourself?