Skip to content

AI agents quick start

An AI agent that runs commands on network devices is a workload: a program with no person at the keyboard. port49 treats it like one. It gets an identity of its own, a grant of its own, and a record of its own in the decision log.

This page is for the operator who sets that up, and for the agent that reads it before it acts. Every sentence is an instruction: it says what to do, and what never to do.

The rules

  1. The agent never holds a person's login. It does not run port49 login, and it is never started from a person's shell with that person's agent running. A command it ran there would be recorded under the person, and the person's grants would be the agent's.
  2. The agent has a subject of its own, with the narrowest roles. The subject names the agent's one identity, which is one of:

    • a workload certificate (spiffe://<trust domain>/<path>)
    • a Kubernetes service account (system:serviceaccount:<namespace>:<name>)
    • a machine OIDC client (app:<client_id>)

    Give it a read-only role first, and widen it only when a human decides to. See Workloads and Roles and grants.

  3. The tooling mints and presents the token. The token never enters the agent's context. Use one of these two, each of which hands the token to the connection and nowhere else:

    • the Ansible lookup, which reads the token from the port49 command
    • the Python library's port49.netmiko.connect with a port49.Workload, which mints with the agent's own certificate (step 4b)

    Never use port49.Agent, and never call connect without workload=: both use a person's session.

    Never run port49 token, port49 workload token or port49 ssh in a shell. Each either uses a person's login, or on success prints the token on standard output, which is the transcript. port49 token --json prints the broker's whole answer, token included.

    Never print, log or cat a token, a certificate's key file, a password, an ID token, or a dump of the environment. Keep no_log: true on every Ansible task that holds one. The agent passes the key file's path to the tooling and never opens it.

  4. A refusal arrives as a reason code. The agent reads the code from its tooling and stops. It never retries a deny, and it never tries another device, another identity or a person's login to get round it. The next section says where the code appears on each path.

  5. Every command is recorded under the agent's principal. See what is recorded.
  6. A device that asks for a person's second factor stays human: the agent's subject has no grant on it. A per_login_mfa requirement does not keep an agent off a device on its own. See per_login_mfa_required devices.

How a refusal reaches the agent

A refusal is a deny with a reason code from the closed list in Reason codes. Read it from the tooling the agent already runs. These are the four paths, and what each one carries.

Path What the agent sees Where the code is
port49 ssh: port49 ssh: <device>: deny <reason>: <detail> on standard error in the line
port49 workload token: port49 workload token: <device>: deny <reason>: <detail> on standard error in the line
The Python library a Denied whose message is port49: <device>: deny <reason>: <detail> .reason and .code, and in the message
The Ansible lookup the failed task's error, port49: <device>: deny <reason>: <detail> the message always holds it, but where you find it depends on the ansible-core version (below)

Three things about that table.

  • port49 ssh: is a person's path, not the agent's. port49 ssh mints through the agent that port49 login started, which holds a person's session. With no such agent, it prints a line that also starts port49 ssh:. An agent with its own identity runs neither.

    If an agent ever reports a line that starts port49 ssh:, it ran port49 ssh, which is a person's path (rule 1). Stop, and tell a human.

  • On ansible-core 2.16 to 2.18 the code is in the warning. Those versions censor the message of a failed no_log task and show only censored. The lookup also raises the same message as a [WARNING] line, printed beside the censored result: read the code there. On 2.19 and later, the task's error holds it.

  • A second factor is the one refusal whose message may not hold the code, and it names a person's step. A device that asks for a proof the agent cannot give is per_login_mfa_required. The Python MFARequired message reads port49: <device>: asks for <methods> at each login, and it could not be given here (<detail>); a person must give it, so an agent stops here and asks one: at a terminal, a person's `port49 ssh <device>` proves it, and the proof covers their port49 agent's further logins for its reuse_for; a workload's proof is a person's touch on its touch_key, and the Ansible lookup raises that same text with [per_login_mfa_required] appended when the detail does not hold the code. port49 ssh is a person's path (rule 1). An agent runs neither it nor port49 token. It stops and asks a human. Read .code and .reason in Python, not the advice.

In Python, a sweep puts each refusal in res.refused instead of raising, so one device does not stop the rest. Read the code there:

import port49

workload = port49.Workload(broker="https://broker.example.com:8443", broker_ca="/etc/port49/ca.pem",
                           cert="/etc/port49/agent-cert.pem", key="/etc/port49/agent-key.pem")
res = workload.tokens(["leaf1", "core1"])
for device, refusal in res.refused.items():
    print(device, refusal.reason or refusal.error)   # a code, never a token

port49.Workload presents the agent's own certificate. port49.Agent presents whatever person logged in on the machine, so an agent never constructs one.

Never print res or res.tokens, and never take a token out of them for a connection of the agent's own. To run a command, use connect (step 4b), which mints for itself.

per_login_mfa_required devices stay human

A device can require a proof at each login: a touch on a hardware key, or a fresh sign-in at the identity provider (see Roles and grants). Keep those devices for people. Two things in the code decide how that works for an agent, and the second is easy to get wrong.

  • A machine is exempt from the requirement by default. A per_login_mfa level applies to a workload only when it says applies_to_machines: true. So a requirement alone does not keep an agent off the device: an agent whose subject is granted there is minted a token, and nothing asks it for a proof.

    What keeps it off is that no grant of the agent's subject reaches the device. For the agent that is deny no_grants_matched, as the worked example below shows.

  • With applies_to_machines: true an agent cannot meet it. A workload proves only with hardware_key_touch, from a PKCS#11 key and a touch key on one token. A person touches that key; an agent has nobody to touch it.

    • Without the proof, the mint is deny per_login_mfa_required.
    • A Kubernetes service account has no key to touch, so it is never proved at all.
    • A requirement the broker cannot verify for the device is deny per_login_mfa_unavailable.

    All three codes are on the Reason codes page.

What the agent does on either code: stop, name the device and the code, and ask a human. It never looks for a touch key, never asks for a PIN, never starts a sign-in, and never runs port49 token, nor the port49 ssh that the MFARequired message names for a person.

The worked example: one read-only agent

One agent, netops-readonly, may run show commands on leaf1 and nothing on core1. The agent's identity is a workload certificate with the SPIFFE ID spiffe://example.com/agent/netops-readonly. Every value here is a placeholder.

1. Identity, subject and grant

The human who administers the policy writes this. The subject lists the agent's one identity and its device username, and the grant ties it to the dc_fabric scope with the read-only role.

platform_set: 1.3.0

keys:
  core:
    current: {version: 1, secret_ref: "file:/run/port49/keys/core-v1"}

key_groups:
  - {match: {cidr: 192.0.2.0/24}, key_ref: core}

scopes:
  - name: dc_fabric
    match: {site: [dc1], role: [leaf]}
    on_missing: exclude

roles:
  - name: read_only
    privilege: readonly
    permit:
      - "show version"
      - "show interfaces status"
      - "show ip interface brief"
      - "show ip bgp summary"
      - "show lldp neighbors"
      - "terminal length *"
    deny:
      - "show running-config **"
      - "show startup-config **"
      - "show tech-support **"
      - "show session-config **"
    max_session_age: 1h

subjects:
  - name: agent_readonly
    identity: ["spiffe://example.com/agent/netops-readonly"]
    username: svc-agent-ro
    token_ttl: 120s

grants:
  - {subject: agent_readonly, scope: dc_fabric, roles: [read_only]}

tests:
  - {subject: agent_readonly, device: leaf1, command: "show version", expect: permit}
  - {subject: agent_readonly, device: leaf1, command: "configure terminal", expect: deny}
  - {subject: agent_readonly, device: leaf1, command: "show running-config", expect: deny}
  - {subject: agent_readonly, device: leaf1, command: "show startup-config", expect: deny}
  - {subject: agent_readonly, device: leaf1, command: "show tech-support", expect: deny}
  - {subject: agent_readonly, device: leaf1, command: "show session-config", expect: deny}
  - {subject: agent_readonly, device: core1, command: "show version", expect: deny}

The permits are an allow-list of the show commands the agent needs, not show **. The allow-list is the fence. A show that prints the device's configuration (show running-config, show startup-config, show tech-support) prints its AAA lines and its local users' secrets. That output would land in the agent's context, so the role also denies each of them.

Over SSH, the denies also hold if someone later widens the permit to show **. Re-run with that permit, and each of the four is deny no_permit_dominates_deny. They hold because an interactive SSH session on arista_eos sends the command with its words spelled out: sh run reaches port49 as show running-config (L21-arista_eos.md; expands_abbreviations: true in platforms/1.3.0/arista_eos.yaml).

EOS ships no policy class for configuration views, so the denies name the commands. Before you grant another platform, add its equivalents from its device page.

Unmeasured

Whether EOS sends a command spelled out over eAPI or NETCONF is unknown, because the lab measured an interactive SSH session only. Over a transport that sends show run as typed, a full-word deny may not bind, and a role widened to show ** would permit it. The allow-list is what protects the agent there.

The agent connects over SSH only: Ansible network_cli or Netmiko through port49.netmiko.connect, never eAPI, httpapi or NETCONF. If a play or a script would use another transport, stop and ask a human.

The inventory has both devices, and only leaf1 is in the scope:

devices:
  - {name: leaf1, platform: arista_eos, addresses: [192.0.2.1], attributes: {site: dc1, role: leaf}}
  - {name: core1, platform: arista_eos, addresses: [192.0.2.2], attributes: {site: dc1, role: core}}

2. Check the policy before it is merged

port49 policy test --policy policy.yaml --inventory inventory.yaml
platform_set: 1.3.0
keys: checked against the default key_secret_refs (env: variables starting PORT49_KEY_, file: under /run/port49/keys); pass --config to check port49d.yaml's
PASS agent_readonly on leaf1: "show version" -> permit permit_dominates
PASS agent_readonly on leaf1: "configure terminal" -> deny no_rule_matched
PASS agent_readonly on leaf1: "show running-config" -> deny no_rule_matched
PASS agent_readonly on leaf1: "show startup-config" -> deny no_rule_matched
PASS agent_readonly on leaf1: "show tech-support" -> deny no_rule_matched
PASS agent_readonly on leaf1: "show session-config" -> deny no_rule_matched
PASS agent_readonly on core1: "show version" -> deny no_grants_matched
7 tests, 0 failed; 0 shadowed denies, 0 unacknowledged; 0 quarantined, 0 projection refusals

The last line is the point: the agent has no grant on core1, so a refusal there is no_grants_matched. Keep a test like it for every device that stays human. A human reads this output and merges the policy (the runbook says why the agent does not).

3. The agent's own check

port49 explain --policy policy.yaml --inventory inventory.yaml --subject agent_readonly \
  --device leaf1 --command "show version"
permit permit_dominates
  subject     agent_readonly
  device      leaf1 (arista_eos)
  grants      dc_fabric [read_only]
  roles       read_only
  level       readonly; max_privilege none
  session     max_session_age 1h0m0s (the least of its roles')
  per_login_mfa none
  command     show version
  rule        show version (read_only) [3 3]
note: arista_eos does not declare config_commands: whether a command typed in configuration mode is sent to port49 at all is unmeasured, so if this is a configuration-mode command the device may apply it whatever this answer says (spec §9, §12)

port49 explain is offline, prints no secret, and exits 0 for a permit or a deny. Read its first line.

4. Run a show command through the Ansible lookup

The play mints once, in a set_fact with no_log: true, in workload mode. Workload mode is set by giving all four of broker, broker_ca, cert and key: they make the lookup run port49 workload token instead of using any person's agent. The agent's certificate and key stay in files that only it can read. Never open them.

- hosts: leaf1
  gather_facts: false
  pre_tasks:
    - name: mint this host's token
      ansible.builtin.set_fact:
        port49: "{{ lookup('port49.client.token', inventory_hostname, field='all',
                           broker='https://broker.example.com:8443', broker_ca='/etc/port49/ca.pem',
                           cert='/etc/port49/agent-cert.pem', key='/etc/port49/agent-key.pem') }}"
      no_log: true
  vars:
    ansible_connection: ansible.netcommon.network_cli
    ansible_network_os: arista.eos.eos
    ansible_user: "{{ port49.username | default(omit) }}"
    ansible_password: "{{ port49.login | default(omit) }}"
    ansible_look_for_keys: false
  tasks:
    - name: show version
      arista.eos.eos_command:
        commands: ["show version"]
      register: out
      no_log: true
    - name: the command's output, and only that
      ansible.builtin.debug:
        var: out.stdout_lines

How the play keeps the token out of the output:

  • The command task keeps no_log: true because it receives ansible_password, so Ansible censors its own result.
  • register: out keeps the result in a variable. The debug prints one key of it, the command's output, which holds no token.
  • Never drop no_log from the mint or the command task to see output, and never debug port49, out or ansible_password.
  • Keep gather_facts: false, keep the mint first, and keep | default(omit) exactly as shown, for the reasons on Install the clients.

If the mint is refused, the play stops for that host. Report the line that holds the code, and stop.

  • On ansible-core 2.19 and later, the error ends with the code, for example port49: core1: deny no_grants_matched: <detail>.
  • On 2.16 to 2.18, the task shows only censored, and the same text is in the [WARNING] line beside it.

Always give all four of broker, broker_ca, cert and key. Leave out any one, and the lookup would use a person's agent instead of the agent's certificate.

The Python Workload and this lookup take a certificate and key, and no token file. A Kubernetes service-account agent is a human's setup (Workloads).

4b. Or run it from Python

port49.netmiko.connect with workload= does three things:

  1. It mints through port49 workload token with the agent's certificate.
  2. It checks the device's host key under its inventory name before the token is sent (Install the clients).
  3. It returns a Netmiko connection.

The token reaches no return value except the connection itself. It is in no exception or traceback. Until disconnect(), two things hold it:

  • the connection, as Netmiko's password and secret, to log in again
  • Netmiko's own log filter, to mask it

Call disconnect(), or use the connection as a context manager, when done. Never print, log or pickle the connection's attributes.

import port49
from port49.netmiko import connect

workload = port49.Workload(broker="https://broker.example.com:8443", broker_ca="/etc/port49/ca.pem",
                           cert="/etc/port49/agent-cert.pem", key="/etc/port49/agent-key.pem")
try:
    conn = connect("leaf1", workload=workload)
except port49.Port49Error as e:
    print(e.device, e.code)   # a code, never a token
    raise SystemExit(1)
try:
    print(conn.send_command("show version"))   # the command's output, and only that
finally:
    conn.disconnect()

A refusal raises before anything connects. Report the code and stop.

  • Denied: its .code and .reason are the reason code.
  • MFARequired: the device asks for a person's second factor (above).

Netmiko keeps the token on the connection as password (and secret with enable=True) to log in again. So never print conn, vars(conn) or either attribute.

Never call connect without workload=. With none, it uses a person's agent (rule 1); with no person's agent, it raises NoAgent. Each connect runs port49 workload token once for its one device and never again.

5. What is recorded

Each command the device asks about reaches port49d, which writes one authorize_command record (Decision log). The agent's records carry:

  • its principal, spiffe://example.com/agent/netops-readonly, never a person's
  • the command, as request.vector
  • the subject that matched, as policy.subject, which here is agent_readonly

So a query on the principal returns the agent's commands and nothing else. A denied command is recorded too, with its reason.

What the agent never does

  • Never runs port49 login, port49 ssh, port49 token or port49 workload token, and never reads a file under the secrets directories (Secrets).
  • Never constructs port49.Agent, and never calls port49.netmiko.connect without workload=.
  • Never runs a command that prints the device's configuration unsanitized (show running-config, show startup-config, show tech-support, or the platform's equivalents).
  • Never retries a deny, and never widens its own policy, grant or role.
  • Never touches a device's AAA configuration. That is a human's change, from the page for the device's platform (Devices).

The steps that set all this up, in order, with the check after each, are in the setup runbook.