Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/stack-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,9 @@ allow = ["kettle"] # every command of the kettle stacklet
- Anything allowed runs, including commands that change something. That
is the admin's decision. Limits ("never above 80 degrees") belong in
the command as checks, not in the agent's prompt.
- No command from the agent may name a path on the host, allowed or
not: `--vault`, `--file` and `messages upload` are refused before they
run, because the API runs on the host.

`stack up agent` writes the allowed commands into the agent's workspace
as an always-loaded skill, one line per command with its `HELP`, so the
Expand Down
16 changes: 16 additions & 0 deletions stacklets/core/famstack-api.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@ def _with_exit(text, code):
return f"{text}{EXIT_MARKER} {code}\n"


# Options that name a path on the host. This API runs on the host, and the
# agent's lines are written by a model that reads email and web pages, so a
# path would let it read, or file into the memory, any file on the Mac.
HOST_PATH_OPTIONS = {"--vault", "--file"}
# Commands whose arguments are a path on the host, whoever allowed them.
HOST_PATH_COMMANDS = [["messages", "upload"]]


def _names_a_host_path(args):
return (any(a.split("=", 1)[0] in HOST_PATH_OPTIONS for a in args)
or any(args[:len(c)] == c for c in HOST_PATH_COMMANDS))


def handle_plaintext(line):
"""Run one allowlisted `stack` command in the CLI's text mode.

Expand All @@ -190,6 +203,9 @@ def handle_plaintext(line):
return _with_exit(f"error: could not parse command ({e})\n", 2)
if not args:
return _with_exit("error: empty command\n", 2)
if _names_a_host_path(args):
return _with_exit("error: a path on the host is not allowed from the agent "
"(--vault, --file, messages upload)\n", 126)
domain_allow = _domain_allow()
if not any(args[:len(p)] == p for p in domain_allow):
allowed = ", ".join(" ".join(p) for p in domain_allow)
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/stacklets/test_agent_vault_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,25 @@ def test_the_agent_cannot_add_a_person(api):



@pytest.mark.parametrize("line", [
"memory search tent --vault /Users",
"memory search tent --vault=/Users",
"memory person show homer --vault /tmp",
"memory capture --file /etc/hosts --by homer",
"memory capture --file=~/.ssh/id_ed25519 --by homer",
"messages upload family /etc/hosts",
])
def test_the_agent_cannot_point_a_command_at_a_path_on_the_host(api, line):
"""The API runs on the host, and what the agent sends is written by a
model that reads email and web pages. An option that names a path would
let it read, or file into the memory, any file on the Mac, so the line
is refused before anything runs, with a status the agent can tell from
an answer."""
reply = api.handle_plaintext(line)
assert "not allowed" in reply
assert reply.rstrip().endswith(f"{api.EXIT_MARKER} 126")


def test_the_agent_can_speak_aloud_without_being_allowed_it(api):
"""Saying something aloud is one of the agent's own abilities, like a
lookup: it works on an instance whose stack.toml allows nothing."""
Expand Down
Loading