# Security Model: par-term Scripting Protocol
## Overview
Script subprocesses communicate with the terminal via a JSON-line protocol
defined in `src/protocol.rs`. Scripts read `ScriptEvent` objects from stdin
and write `ScriptCommand` objects to stdout. Most commands (`Log`, `SetPanel`,
`ClearPanel`, `Notify`, `SetBadge`, `SetVariable`) are low-risk display
operations. Two commands — **`WriteText`** and **`RunCommand`** — have the
potential to cause significant harm. Both are implemented, and both are gated
by the authorization requirements set out below.
---
## Commands Requiring Security Review
### WriteText
`WriteText` injects an arbitrary string into the PTY as if the user had typed
it. This is equivalent to the existing `SendText` trigger action and is
inherently dangerous because:
- It can execute arbitrary shell commands in the active terminal session.
- It can inject terminal escape sequences that reposition the cursor, corrupt
output, or exfiltrate data.
- It can be used to modify shell history, override aliases, or change
environment variables.
### RunCommand
`RunCommand` spawns an arbitrary external process as a direct child of the
terminal emulator. This is distinct from `WriteText` in that it does **not**
go through the PTY shell — it is a raw `std::process::Command::spawn()` call.
This is dangerous because:
- It can execute any binary visible on `$PATH` or reachable by absolute path.
- It bypasses the active shell's environment and job control.
- It can silently exfiltrate data, open network connections, or modify files
without any visible output in the terminal.
- It can re-invoke the terminal emulator itself to spawn additional processes.
---
## Threat Model
Scripts are user-configured subprocesses launched from `ScriptConfig` entries
in `~/.config/par-term/config.yaml`. The script binary is therefore implicitly
trusted (it was placed there by the user). However, the trust must be bounded
because:
1. **Supply-chain attacks**: A malicious package could replace a trusted script
with one that emits `RunCommand` payloads to exfiltrate credentials.
2. **Injection through event data**: A malicious terminal sequence (from `cat`
of a hostile file) could produce a `TriggerMatched` or `CwdChanged` event
whose payload is forwarded to the script via `ScriptEvent`. A poorly written
script could then reflect that data back as a `RunCommand` or `WriteText`
payload. This is analogous to the terminal-injection risk documented in the
trigger security model (`src/app/triggers.rs`).
3. **Compromised scripts**: A script may be modified after initial deployment
(e.g., via a writable file on a shared filesystem). The terminal cannot
detect that the trusted binary has been replaced.
4. **Unbounded execution rate**: Without rate limiting, a script could emit
thousands of `RunCommand` or `WriteText` commands per second as a
denial-of-service attack against the host system.
### Attack Surface Summary
| Malicious terminal output reflected by script | Terminal injection / arbitrary command | `require_consent` flag + denylist |
| Compromised script binary | Arbitrary command execution | User consent prompt on first execution + file-hash check (future) |
| Rapid-fire command emission | DoS / resource exhaustion | Per-script rate limiting |
| Embedded escape sequences in `WriteText` | Terminal escape injection | Strip or validate ANSI/VT sequences |
---
## Authorization Requirements
### When WriteText May Be Used
`WriteText` execution requires **all** of the following conditions to be met:
1. **Explicit opt-in in `ScriptConfig`**: The script's configuration entry must
include `allow_write_text: true`. This field must default to `false`. A
script that does not declare this permission must never be allowed to write
to the PTY, even if it emits a `WriteText` command.
2. **VT sequence stripping**: Before the text is written to the PTY, any ANSI
escape sequences (CSI, OSC, DCS, APC, PM, SOS sequences and raw ESC-prefixed
sequences) must be stripped or the entire `WriteText` command must be
rejected with a log warning.
3. **Rate limiting**: No more than `write_text_rate_limit` writes per second
(suggested default: 10/s). Commands that exceed the rate limit are dropped
with a warning log.
4. **User confirmation**: Unless the script sets `prompt_before_write_text:
false`, the sanitized payload is queued to the automation confirmation
dialog rather than written. The dialog shows the exact text that will reach
the PTY, with control characters escaped so the submitting newline is
visible, and offers Allow Once / Always Allow / Deny. An "Always Allow"
grant is scoped to that one script and that exact text for the session.
This check runs *after* rate limiting, so a chatty script cannot stack
dialogs faster than it could write; at most eight confirmations may be
pending at once, and further payloads are dropped with a warning log.
Stripping escape sequences is what makes this step necessary rather than
sufficient: the payload that runs a command is ordinary printable text plus
a newline, and both survive sanitization by design.
### When RunCommand May Be Used
`RunCommand` execution requires **all** of the following conditions to be met:
1. **Explicit opt-in in `ScriptConfig`**: The script's configuration entry must
include `allow_run_command: true`. This field must default to `false`.
2. **Command denylist check**: The command string must be checked against
`par_term_config::check_command_denylist()` (the same denylist used for
trigger `RunCommand` actions). Any match must block execution and emit a
warning log. The denylist covers:
- Destructive file operations (`rm -rf /`, `dd if=`, `mkfs.*`)
- Shell evaluation (`eval`, `exec`)
- Credential/key exfiltration (`.ssh/id_*`, `.gnupg/`, `ssh-add`)
- Pipe-to-shell patterns (`| bash`, `| sh`)
- System manipulation (`chmod 777`, `chown root`, `passwd`, `sudoers`)
3. **Optional user consent prompt** (recommended for `allow_run_command: true`
scripts): The first time a script emits `RunCommand`, the terminal should
display a one-time confirmation dialog: "Script '<name>' wants to run
'<command>'. Allow?" with options: Allow Once / Allow Always / Deny. The
"Allow Always" choice is persisted per (script-name, command-hash) pair in
the session.
4. **Rate limiting**: No more than `run_command_rate_limit` executions per
second (suggested default: 1/s). Commands that exceed the rate limit are
dropped with a warning log.
5. **No shell expansion**: The command string must be split using shell-word
tokenisation (not passed to `/bin/sh -c`) to prevent metacharacter
injection. Use `shlex`-style splitting or a fixed-token split on whitespace
for the initial implementation.
---
## Proposed ScriptConfig Permission Fields
When `WriteText` and `RunCommand` are implemented, the following fields must be
added to `ScriptConfig` in `par-term-config/src/scripting.rs`:
```rust
/// Allow this script to write text to the active PTY.
/// Defaults to false. Must be explicitly set to true.
#[serde(default)]
pub allow_write_text: bool,
/// Allow this script to spawn external commands via RunCommand.
/// Defaults to false. Must be explicitly set to true.
#[serde(default)]
pub allow_run_command: bool,
/// Maximum WriteText commands per second (0 = use default of 10).
#[serde(default)]
pub write_text_rate_limit: u32,
/// Maximum RunCommand executions per second (0 = use default of 1).
#[serde(default)]
pub run_command_rate_limit: u32,
```
---
## Dispatcher Security Gate (window_manager.rs)
The command dispatcher in `src/app/window_manager.rs` (the match on
`ScriptCommand` variants) must enforce the following checks before `WriteText`
and `RunCommand` are implemented:
```
ScriptCommand::WriteText { text } =>
1. Reject if !script_config.allow_write_text
2. Strip VT/ANSI escape sequences from text
3. Check rate limit (write_text_rate_limit per second)
4. Unless prompt_before_write_text is false, or the user already chose
"Always Allow" for this script and this exact text, queue the payload
for confirmation instead of writing it
5. Write to PTY via terminal_manager.write_to_pty()
ScriptCommand::RunCommand { command } =>
1. Reject if !script_config.allow_run_command
2. Tokenise command (no shell expansion)
3. check_command_denylist(cmd, &args) -> block if Some(_)
4. Check rate limit (run_command_rate_limit per second)
5. Optional: prompt user for consent
6. Spawn via std::process::Command (not /bin/sh -c)
```
---
## Relationship to Trigger Security Model
The trigger security model (`src/app/triggers.rs`) addresses the same
`RunCommand` / `SendText` concerns for trigger-fired actions:
- `require_user_action` flag (default `true`) blocks dangerous trigger actions.
- `check_command_denylist()` from `par-term-config` blocks known-bad patterns.
- Per-trigger rate limiting prevents flooding.
The scripting protocol security model is intentionally aligned with this
approach so that the same denylist and rate-limiting infrastructure can be
reused. The key difference is that scripts are long-lived subprocesses (not
one-shot trigger responses), so rate limits apply across the script's lifetime
rather than per trigger-fire.
---
## Audit Finding Reference
This document addresses **AUDIT FINDING M6** (Medium Security):
"Scripting protocol defines `WriteText`/`RunCommand` (unimplemented) — needs
security model when added."
Both commands are now implemented, with the `allow_write_text` /
`allow_run_command` permission flags (both defaulting to `false`), the
dispatcher-level checks described above, and tests for the denylist,
rate-limiting and VT-stripping logic. `WriteText` additionally requires user
confirmation by default (SEC-013), because sanitization cannot tell a benign
payload from a command line — see condition 4 under "When WriteText May Be
Used".