fumi lets web pages trigger local executables. That is an intentionally dangerous capability, so the design is conservative: the bridge between the browser and your shell exposes the smallest possible surface, and almost every restriction is enforced on the host side where an attacker cannot reach.
- A compromised web page running an action. The page cannot forge calls with arbitrary paths, read files outside
scripts/, or pass shell-interpreted arguments. - A malicious or buggy action trying to escape. It still cannot resolve paths outside
scripts/, follow symlinks out, or execute non-script files. - An unrelated extension or process trying to talk to
fumi-host. The manifest'sallowed_originspins exactly the extension IDs baked into the build; Chrome refuses to broker connections from anyone else.
- The host machine already being compromised. If an attacker can write to
~/.config/fumi/scripts/, they can run anything you can run. fumi assumes the filesystem is trustworthy. - Scripts you write yourself. fumi will happily execute
rm -rf ~if you put it in a script. Treat every script as code you own and audit. - Payload-level attacks. If your script parses the payload carelessly (shell interpolation, SQL concatenation, arbitrary code execution), fumi cannot help. Treat payloads as untrusted input.
- A compromised Chrome profile or extension signing key. If the attacker controls the pinned extension ID, they can call the host. Protect your Chrome Web Store developer account accordingly.
fumi-host speaks JSON-RPC 2.0 over Chrome's Native Messaging stdio transport. The entire API is three methods:
actions/list— returns parsed frontmatter fromactions/*.js. Read-only.scripts/run— executes a file inscripts/with a JSON payload on stdin.host/version— returns the host's build version string (the same value asfumi-host --version). Read-only metadata; the extension uses it to warn when its version and the host's drift apart.
There is no file read, no file write, no directory list, no environment introspection, no "eval this code", and no way to pass a path or command outside of a scripts/ entry. Adding one would require a new method, a host rebuild, and a fumi setup --force.
Every scripts/run call runs the candidate path through these checks, in order. Any failure aborts before spawn.
| Rejected when | Error code |
|---|---|
Path is absolute or contains .. |
SCRIPT_INVALID_PATH |
Resolved real path is outside scripts/ |
SCRIPT_INVALID_PATH |
| File does not exist | SCRIPT_NOT_FOUND |
| File is a symlink (before or after resolution) | SCRIPT_NOT_REGULAR_FILE |
| File is not a regular file (directory, device, FIFO, socket) | SCRIPT_NOT_REGULAR_FILE |
| Owner-executable bit is unset | SCRIPT_NOT_EXECUTABLE |
Symlinks are rejected rather than followed. A compromised action cannot plant a symlink to /bin/sh inside scripts/ to escape.
- No shell. Scripts are
exec'd directly; there is nosh -c, so shell metacharacters in payloads are inert. - No argv injection. The host never passes user-controlled arguments.
argv[0]is the script path and nothing else. - Stdin-only payload. The JSON payload is written to the child's stdin. A script that never reads stdin cannot be influenced by the payload at all.
- Environment scrubbed. All
FUMI_*variables from the parent environment are dropped; onlyFUMI_STOREis re-set. Callers cannot smuggle state via environment variables. - Working directory pinned. Cwd is the directory containing the script, not wherever the extension happened to start.
These exist to keep a single bad script from hanging or flooding the host:
| Cap | Default | Error on overflow |
|---|---|---|
| Wall-clock runtime | 30 s (configurable) | EXEC_TIMEOUT (SIGTERM, then SIGKILL after 500 ms) |
| Stdout captured | 768 KiB | EXEC_OUTPUT_TOO_LARGE |
| Stderr captured | 128 KiB | EXEC_OUTPUT_TOO_LARGE |
| Native Messaging message | 1 MiB | PROTO_PARSE_ERROR / INTERNAL |
These are hard limits enforced in the host. A runaway script is killed; a flood of output is truncated and the call rejects.
The Native Messaging manifest's allowed_origins list contains exactly one ID, compiled into the fumi binary at build time from extensionID in cmd/fumi/constants.go. Both the Chrome Web Store build and unpacked dev builds (via the dev-key injection in scripts/build-dev.sh) resolve to that same ID, so a single pin covers both.
Any other extension that tries to open a port to com.tkrmt.fumi is rejected by Chrome before fumi-host is even spawned. Changing the pinned ID requires rebuilding fumi and re-running fumi setup --force.
- The store at
~/.config/fumi/is created with mode0700; only your user can read it. config.tomlis0600.fumi uninstallremoves the manifest but leaves the store, so removing fumi does not delete your scripts. Delete~/.config/fumi/manually if that is what you want.
Error codes are returned in JSON-RPC error.code and are stable across versions. Protocol-level codes (PROTO_*, INTERNAL) match JSON-RPC 2.0 conventions; domain codes are in the -33xxx range.
| Code | Numeric | When |
|---|---|---|
PROTO_PARSE_ERROR |
-32700 | JSON parse failed |
PROTO_INVALID_REQUEST |
-32600 | Missing/invalid required fields |
PROTO_METHOD_NOT_FOUND |
-32601 | Unknown method |
PROTO_INVALID_PARAMS |
-32602 | Param validation failed |
INTERNAL |
-32603 | Host-side bug |
STORE_NOT_FOUND |
-33001 | Store root missing |
STORE_CONFIG_INVALID |
-33002 | config.toml parse error |
STORE_ACTIONS_TOO_LARGE |
-33010 | actions/list response exceeds 1 MiB |
STORE_FRONTMATTER_INVALID |
-33011 | Action frontmatter rejected |
SCRIPT_INVALID_PATH |
-33020 | Path traversal / outside scripts/ |
SCRIPT_NOT_FOUND |
-33021 | Script missing |
SCRIPT_NOT_REGULAR_FILE |
-33022 | Symlink or non-regular file |
SCRIPT_NOT_EXECUTABLE |
-33023 | Owner-executable bit unset |
EXEC_TIMEOUT |
-33030 | Timeout exceeded |
EXEC_OUTPUT_TOO_LARGE |
-33031 | Stdout or stderr overflow |
EXEC_SPAWN_FAILED |
-33032 | exec itself failed |