Install
$ agentstack add skill-vinnie357-claude-skills-ports Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.
Security review
⚠ Flagged1 finding(s); flagged for manual review. · v0.1.0 How review works →
- • Prompt-injection patterns
- • Secret / credential exfiltration
- • Dangerous shell & filesystem operations
- • Untrusted network calls
- • Known-malicious package signatures
- high Destructive filesystem operation.
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ● Shell / process execution Used
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming — see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps — measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Elixir Ports
Ports are Elixir's mechanism for communicating with external OS programs via stdin/stdout. A Port is not a network port — it is a process-like entity that manages an external OS program, forwarding messages as bytes and delivering responses back to the owning Elixir process.
When to Use This Skill
Activate when:
- Spawning and communicating with external programs or scripts
- Implementing binary protocols over stdin/stdout
- Wrapping a port in a GenServer for lifecycle management
- Deciding between Port, NIF, C Node, or
System.cmd - Handling port crashes, exit status, or monitoring ports
- Working with long-running external processes
Port Fundamentals
Open a port with Port.open/2. The most common name forms are {:spawn, command} and {:spawn_executable, path}.
# Spawn by shell command string (uses shell, PATH lookups apply)
port = Port.open({:spawn, "cat"}, [:binary])
# Spawn executable directly (no shell, preferred for security)
port = Port.open({:spawn_executable, "/usr/bin/python3"}, [
:binary,
args: ["-u", "my_script.py"]
])
Send data to the port and receive from it:
# Send bytes to the external process's stdin
Port.command(port, "hello\n")
# Receive response — messages arrive in the process mailbox
receive do
{^port, {:data, data}} ->
IO.puts("Got: #{data}")
end
Close the port explicitly or let it close when the owner process exits:
Port.close(port)
# The port will reply {port, :closed} after the external process exits
Port Options Reference
| Option | Description | |--------|-------------| | :binary | Deliver data as binaries (recommended; default is charlists) | | {:packet, N} | Prefix each message with N-byte length header (N = 1, 2, or 4) | | {:line, L} | Deliver complete lines up to L bytes; partial lines flagged | | :stream | No framing — raw bytes delivered as received (default) | | :exit_status | Send {port, {:exit_status, code}} when the program exits | | :use_stdio | Use stdin/stdout for communication (default) | | :stderr_to_stdout | Merge stderr into stdout stream | | {:cd, dir} | Set working directory for the external process | | {:env, env_list} | Set environment variables (REPLACES the entire environment) | | {:args, arg_list} | Argument list when using :spawn_executable | | {:arg0, string} | Override argv[0] for the external process | | :parallelism | Hint to scheduler for improved throughput |
Note on {:env, env_list}: This option completely replaces the OS environment, it does not merge with the current environment. To extend the current environment, merge explicitly before passing it.
Communication Patterns
Stream Mode (default)
Bytes are delivered as they arrive with no framing. Use :binary to get binaries instead of charlists.
port = Port.open({:spawn_executable, "/bin/cat"}, [:binary, :stream])
Port.command(port, "some data")
receive do
{^port, {:data, chunk}} -> IO.inspect(chunk)
end
Packet Mode
Use {:packet, N} for message framing. The external program must also implement the same N-byte big-endian length prefix.
# Elixir side
port = Port.open({:spawn_executable, "/usr/local/bin/my_worker"}, [
:binary,
{:packet, 4}
])
Port.command(port, encode_message("hello"))
receive do
{^port, {:data, response}} -> decode_message(response)
end
The external program reads a 4-byte big-endian integer as the message length, then reads that many bytes. It writes responses with the same framing.
Line Mode
port = Port.open({:spawn_executable, "/usr/bin/python3"}, [
:binary,
{:line, 4096},
args: ["script.py"]
])
receive do
{^port, {:data, {:eol, line}}} ->
IO.puts("Complete line: #{line}")
{^port, {:data, {:noeol, partial}}} ->
IO.puts("Partial line (too long): #{partial}")
end
Receiving Exit Status
port = Port.open({:spawn, "false"}, [:binary, :exit_status])
receive do
{^port, {:exit_status, code}} ->
IO.puts("Exited with code #{code}")
end
GenServer Port Wrapper
Wrapping a port in a GenServer is the standard OTP pattern for managing external processes. The GenServer owns the port, handles messages, and exposes a clean API.
defmodule MyApp.ExternalWorker do
use GenServer
@timeout 5_000
# Client API
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def call(payload) do
GenServer.call(__MODULE__, {:call, payload}, @timeout)
end
# Server Callbacks
@impl true
def init(_opts) do
executable = System.find_executable("my_worker") ||
raise "my_worker not found in PATH"
port =
Port.open({:spawn_executable, executable}, [
:binary,
{:packet, 4},
:exit_status
])
ref = Port.monitor(port)
{:ok, %{port: port, ref: ref, caller: nil}}
end
@impl true
def handle_call({:call, payload}, from, %{port: port} = state) do
encoded = :erlang.term_to_binary(payload)
Port.command(port, encoded)
{:noreply, %{state | caller: from}}
end
@impl true
def handle_info({port, {:data, data}}, %{port: port, caller: caller} = state) do
response = :erlang.binary_to_term(data)
GenServer.reply(caller, {:ok, response})
{:noreply, %{state | caller: nil}}
end
@impl true
def handle_info({port, {:exit_status, code}}, %{port: port} = state) do
{:stop, {:port_exited, code}, state}
end
@impl true
def handle_info({:DOWN, ref, :port, _port, reason}, %{ref: ref} = state) do
{:stop, {:port_down, reason}, state}
end
@impl true
def terminate(_reason, %{port: port}) do
if Port.info(port) != nil do
Port.close(port)
end
:ok
end
end
Key points in this pattern:
- Store the port in GenServer state
- Monitor the port with
Port.monitor/1to receive:DOWNmessages - Use
{:packet, N}for reliable message framing - Store the calling
frompid when waiting for a response - Reply via
GenServer.reply/2fromhandle_infowhen data arrives - Always check
Port.info(port)before closing interminate/2
Supervision
Add the GenServer to a supervision tree:
children = [
{MyApp.ExternalWorker, []},
# ...
]
Supervisor.start_link(children, strategy: :one_for_one)
When the external process crashes, the port sends an exit message, the GenServer stops, and the supervisor restarts it.
Port Lifecycle and Error Handling
Port Monitor
Use Port.monitor/1 (available since Elixir v1.6) to receive a {:DOWN, ref, :port, object, reason} message when a port closes:
port = Port.open({:spawn, "my_cmd"}, [:binary])
ref = Port.monitor(port)
receive do
{:DOWN, ^ref, :port, _port, reason} ->
IO.puts("Port closed: #{inspect(reason)}")
end
Checking if a Port is Alive
case Port.info(port) do
nil -> :port_closed
info -> {:alive, info}
end
Port Ownership Transfer
Transfer ownership with Port.connect/2. The old owner receives {port, :connected}:
Port.connect(port, new_owner_pid)
Crash Semantics
- When the port owner process terminates, the port closes automatically, and the external program receives SIGTERM.
- When the external program exits, the port sends
{port, {:exit_status, code}}(if:exit_statuswas set) and closes. - Ports are linked to their owning process by default.
Decision Guide: Port vs NIF vs C Node vs System.cmd
| Aspect | Port | NIF | C Node | System.cmd | |--------|------|-----|--------|-----------| | Crash isolation | Yes — OS process | No — crashes the VM | Yes — OS process | Yes — subprocess | | Latency | IPC overhead | Near zero | Message passing | Blocking, high | | Complexity | Low–Medium | High | Very high | Very low | | Scheduler impact | None | Blocks (use dirty NIFs for >1ms) | None | Blocks caller only | | Security boundary | OS process isolation | Full VM access | OS process isolation | OS process isolation | | Best for | Long-running, streaming | Sub-millisecond sync calls | Legacy C integration | One-shot commands |
Use Port when: the external program runs continuously, streams data, or must be isolated from VM crashes.
Use System.cmd/3 when: running a command once, synchronously, and the result is needed before proceeding. Returns {output, exit_status}.
Use NIF when: calling a C function with sub-millisecond latency requirements. Use dirty NIFs (ERL_NIF_DIRTY_JOB_CPU_BOUND or ERL_NIF_DIRTY_JOB_IO_BOUND) for work exceeding ~1ms to avoid blocking schedulers.
Use C Node when: integrating with an existing large C codebase that uses erl_interface/ei.
System.cmd Example
{output, 0} = System.cmd("ls", ["-la", "/tmp"],
cd: "/",
stderr_to_stdout: true
)
System.cmd/3 blocks the calling process until the command completes. It does not stream data.
Security Considerations
Prefer :spawn_executable Over :spawn
{:spawn, command} passes the command through a shell, which enables shell injection:
# UNSAFE — shell injection risk
user_input = "file.txt; rm -rf /"
Port.open({:spawn, "cat #{user_input}"}, [:binary])
# SAFE — no shell, arguments passed directly
Port.open({:spawn_executable, "/bin/cat"}, [
:binary,
args: [user_input]
])
Always use {:spawn_executable, path} with args: when any part of the command comes from user input or external data.
Validate Executable Paths
# Find and validate the executable at startup, not per-request
executable = System.find_executable("my_tool") ||
raise "my_tool executable not found in PATH"
Store the validated path in process state so it is resolved once at startup.
Environment Variable Replacement
{:env, env_list} completely replaces the OS environment. To avoid accidentally stripping PATH or other required variables:
# Merge with current environment
base_env = System.get_env() |> Map.to_list()
Port.open({:spawn_executable, executable}, [
:binary,
env: base_env ++ [{"MY_VAR", "value"}]
])
Note: {:env, env_list} expects a list of {charlist, charlist} or {binary, binary} tuples. Use String.to_charlist/1 if mixing types.
Windows Batch File Injection
On Windows, spawning .bat or .cmd files via :spawn passes through cmd.exe, which has its own injection vectors. Use explicit executable paths and validate all arguments.
Testing Port-Based Code
Mock the Port Interaction
Test the GenServer wrapper by sending fake port messages directly:
defmodule MyApp.ExternalWorkerTest do
use ExUnit.Case, async: true
test "call sends to port and returns response" do
{:ok, pid} = MyApp.ExternalWorker.start_link([])
# Retrieve the port from state for inspection
%{port: port} = :sys.get_state(pid)
# Simulate the port sending a response
fake_response = :erlang.term_to_binary({:ok, "result"})
send(pid, {port, {:data, fake_response}})
# Verify state updated (caller cleared)
%{caller: nil} = :sys.get_state(pid)
end
test "handles port exit" do
{:ok, pid} = MyApp.ExternalWorker.start_link([])
ref = Process.monitor(pid)
%{port: port} = :sys.get_state(pid)
send(pid, {port, {:exit_status, 1}})
assert_receive {:DOWN, ^ref, :process, ^pid, {:port_exited, 1}}
end
end
Integration Test with a Real Port
For integration tests, use a real external program:
test "communicates with cat process" do
port = Port.open({:spawn_executable, "/bin/cat"}, [:binary, :stream])
Port.command(port, "hello")
assert_receive {^port, {:data, "hello"}}, 1_000
Port.close(port)
end
Use assert_receive with a timeout rather than bare receive in tests to avoid hanging on failure.
Common Pitfalls
Orphan processes on VM crash: If the BEAM VM is killed with SIGKILL (not SIGTERM), external port processes may not receive SIGTERM and can persist. Design external programs to detect closed stdin and exit.
Charlists vs binaries: Without :binary, data arrives as charlists (lists of integers). Always use :binary unless working with legacy code that expects charlists.
Environment replacement: {:env, list} replaces the entire environment. See the Security section for how to merge with the current environment.
Blocking the GenServer: A GenServer that sends a command and awaits a reply synchronously in handle_call will block the entire GenServer during the wait. The pattern above avoids this by storing the from and replying in handle_info.
Not monitoring the port: Without Port.monitor/1 or :exit_status, a silently exiting external process leaves the GenServer in a state where it will never receive a reply.
Packet mode mismatch: If the Elixir side uses {:packet, 4} but the external program uses a different framing, all messages will be corrupted or silently dropped. Verify the framing protocol matches on both sides.
References
See references/erlang-ports.md for:
- Complete
open_port/2Erlang option reference - Binary protocol wire format and byte calculations
erl_interface/eiC library overview- Port driver architecture (linked-in drivers)
- ERTS scheduling and backpressure options
Anti-Fabrication
Verify all port behavior claims through tool execution. Use Port.info/1 to confirm port state before asserting properties. Run actual port commands and check exit statuses rather than assuming behavior. Reference core:anti-fabrication for the complete validation methodology.
Key Principles
- Use
:spawn_executable: Avoid shell injection by passing arguments directly - Wrap ports in GenServer: Follow OTP patterns for lifecycle management and supervision
- Monitor the port: Use
Port.monitor/1to detect unexpected port closure - Use
{:packet, N}for framing: Stream mode requires external framing logic - Design for orphan processes: External programs should detect stdin closure and exit
- Test with
assert_receive: Avoid barereceivein tests to prevent hangs - Prefer Port over NIF for isolation: NIF crashes bring down the entire VM
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: vinnie357
- Source: vinnie357/claude-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.