Skip to content

exec and process

exec runs commands. process drives the ones that are still running.

Together they are Kerkov's whole command-execution surface — there is no separate shell tool. A command that prints and quits is one exec call; a command you keep talking to (an SSH shell, a REPL, msfconsole) is one exec call that returns a session id, and then a conversation of process calls against it.

The mechanism behind both — PTY allocation, terminal emulation, buffering, process-tree termination — is documented in Process management.

exec

Parameters

Parameter Type Default Description
command string required The command line to run.
mode one_shot | session | daemon required What kind of work this is. See below.
stdio auto | pty | pipe auto Terminal shape. auto gives every mode a PTY. pipe forces plain subprocess semantics; rejected with mode=session.
workdir string engagement workspace Working directory for the command.
timeout integer 120 Overall timeout in seconds, after which the process tree is killed. one_shot only; the other modes reject a non-default value.
no_output_timeout integer 60 Kill the process after this many seconds with no output. one_shot only; session and daemon reject a non-default value.

Modes

mode is required because it is not inferable from the binary, and getting it wrong is the difference between a useful session and a timeout.

Mode For Returns Timers
one_shot Runs and exits on its own: ls, curl, nmap, a script that prints and quits. The output, once it finishes. Both timeout and no_output_timeout apply.
session Anything you will send more input to: SSH and su shells, REPLs (python3 -i, msfconsole, sqlite3), and anything needing a terminal (sudo password prompts, vim, less). A session_id, immediately. Neither. Interactive sessions pause between commands by design.
daemon Long-lived processes you start and leave: openvpn, tunnels, listeners. A session_id, immediately. Neither. A daemon going quiet after its handshake is normal.

The rule of thumb: if you will send a second command to the same target, you want session, not a second one_shot. Two one_shot calls are two unrelated processes, so cd /tmp followed by ls lists your home directory.

Approval

Prompts by default. exec is the one tool that offers no broad rung at all: an approval here saves as that exact command or nothing.

This is deliberate. argv[0] is not a security boundary — sudo * and bash * are blanket grants wearing a program name, and curl * exfiltrates. Widening exec should be a considered edit to approvals.yaml, not a keystroke at a prompt. timeout and no_output_timeout never reach a saved rule.

Three deny rules ship enabled, and a deny is refused without a prompt:

- deny: "exec({'command': 're:.*rm.*-[rRf].*'})"
- deny: "exec({'command': 're:(^|[^a-z])sudo($|[^a-z])'})"
- deny: "exec({'command': 're:.*(mkfs|shred).*'})"

The sudo deny is the one operators most often relax; see Approvals for how to override packaged rules from ~/.kerkov/approvals.yaml. That deny rule is the only thing enforcing the sudo boundary — REQUIRE_SUDO_APPROVAL is declared in the configuration but not read by the runtime, so changing it has no effect.

Set EXECUTION_ENABLED=false to refuse every exec call outright; the result comes back with metadata.policy = "execution_disabled".

Returns

For one_shot, the command's output, with metadata.status of completed or failed alongside exit_code, duration_ms, cwd, mode, stdio, and — on a kill — exit_signal, timed_out and no_output_timed_out, so a timeout is distinguishable from a non-zero exit.

For session and daemon, a line naming the session id plus whatever the process printed while starting. Metadata carries status: "running", session_id, pid, started_at, cwd, mode, and stdio.

stdio in the metadata is what the process actually got, which can differ from what was asked: auto requests a PTY, and a platform that cannot supply one falls back to pipes with a logged warning. Check it before assuming interactive behavior works.

Examples

A one-shot scan:

{ "command": "nmap -sV -p- 10.10.10.5", "mode": "one_shot", "timeout": 900 }

An interactive session, then driving it:

{ "command": "sshpass -p 'hunter2' ssh -o StrictHostKeyChecking=no bob@10.10.10.5", "mode": "session" }

{ "action": "paste", "session_id": "4f2a9c1e", "text": "id; uname -a" }
{ "action": "submit", "session_id": "4f2a9c1e" }
{ "action": "poll", "session_id": "4f2a9c1e", "timeout": 3000 }

A daemon left running for the rest of the engagement:

{ "command": "openvpn --config /home/kali/htb.ovpn", "mode": "daemon" }

Machine-readable output, no terminal:

{ "command": "kubectl get pods -o json", "mode": "one_shot", "stdio": "pipe" }

pipe is worth reaching for when a PTY would corrupt the output: it keeps stderr separate, reports isatty() as false, and suppresses the escape sequences a program emits when it thinks it is on a terminal.

process

Manages the sessions exec started. Every action but list needs a session_id.

Actions

Action What it does Default approval
list Every session, running and finished. Allowed
poll New output since the last read, optionally waiting for more. Allowed
log Output history: the last 200 lines, or a window you page into. Allowed
paste Send text into a PTY session. Prompts
submit Press Enter. Prompts
write Send raw bytes to stdin. Pipe sessions only. Prompts
send_keys Send control or special keys. Prompts
kill Terminate the session's process tree. Prompts

Typing into a PTY session is paste then submit, not write. write exists for stdio=pipe sessions, where there is no terminal to paste into. send_keys is only for keys that have no text form — C-c, Up, Tab, Escape, F1–F12 — never for typing.

Parameters

Parameter Type Default Applies to
action string required —
session_id string — Every action except list
text string — paste
bracketed boolean auto-detected paste. Overrides the session's tracked bracketed-paste state. If a paste fails — a password prompt is the usual case — retry with false.
data string — write
eof boolean false write. Close stdin afterwards.
keys array[string] — send_keys. Key tokens such as ["C-c"], ["Up"], ["S-Tab"].
hex_bytes array[string] — send_keys. Raw bytes, e.g. ["0x04"] for EOF.
literal string — send_keys. A literal string, sent as-is.
offset integer 0 log. Zero-based first line of the window.
limit integer — log. Lines to return from offset. Omit both it and offset for the default tail.
timeout integer — poll. Milliseconds to wait for new output, clamped to 120000.

Approval

An approval saved from a process prompt widens to the action — kill and log are not the same grant. Four read-only actions ship auto-approved:

- allow: "process({'action': 'list'})"
- allow: "process({'action': 'poll'})"
- allow: "process({'action': 'log'})"

process has no capability gate of its own. It drives sessions exec already started, and making them unkillable by turning execution off mid-engagement would be worse than the alternative.

Returns

list returns a table of sessions with running_count and finished_count in metadata. poll and log return output text with status and session_id; poll waits in 250 ms slices up to its timeout and returns whatever arrived. kill reports status: "killed".

log tails by default, and only by default: with limit unset and offset unset or 0, it returns the last 200 lines and notes the total when there were more. Any limit, or a non-zero offset, switches it to a forward window — lines[offset : offset + limit], counted from the start of the buffer, running to the end when only offset was given — so limit=50 on its own returns the first 50 lines, not the last. Metadata always carries total_lines; read it from a default log, then pass an explicit offset to page from a known point.

For a terminal-emulated session — which is what mode=session over a PTY gets — poll returns newly rendered screen content and log returns the rendered screen, rather than the raw byte stream with escape sequences stripped. The distinction matters when a program redraws in place: less and vim produce a screen, not a transcript.

Examples

Interrupt a running command without killing the session:

{ "action": "send_keys", "session_id": "4f2a9c1e", "keys": ["C-c"] }

Read the tail of a daemon's log — the last 200 lines, which is what log does with no pagination:

{ "action": "log", "session_id": "b70d3115" }

Page forward through a long log, 50 lines at a time from line 400:

{ "action": "log", "session_id": "b70d3115", "offset": 400, "limit": 50 }

Send a password to a prompt that rejected a bracketed paste:

{ "action": "paste", "session_id": "4f2a9c1e", "text": "hunter2", "bracketed": false }
  • Process management — PTY adapters, buffer caps, session TTL, termination and the key encoder.
  • container_lifecycle — prefer it over exec docker compose up; it polls readiness locally instead of filling context.
  • sbom_generate, sast_scan, zap — prefer these over driving Syft, Grype, OpenGrep or ZAP through exec; they bound their output and keep full reports as artifacts.
  • Approvals — modes, rules, and the deny list.