mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-01 15:36:08 +00:00
151 lines
5.6 KiB
Python
151 lines
5.6 KiB
Python
"""The read-only session grant reads the session's folders, not the machine (OPE-130).
|
|
|
|
`readonly.py` vets what a command DOES — carefully, and fail-closed. It said nothing about
|
|
what a command READS, so a grant the user reads as "stop asking about my project files"
|
|
also covered ~/.aws/credentials, another repository's history, and OpenWorker's own secrets
|
|
file. The self-protection floor does not cover that: it guards those files against
|
|
modification, not reading.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from coworker.permissions import Mode, PermissionEngine
|
|
from coworker.readonly import is_readonly_command, read_targets
|
|
|
|
|
|
@pytest.fixture()
|
|
def session(tmp_path):
|
|
"""A session whose granted root is `repo/`, with the grant already clicked."""
|
|
root = tmp_path / "repo"
|
|
(root / "src").mkdir(parents=True)
|
|
(root / "build.log").write_text("boom", encoding="utf-8")
|
|
engine = PermissionEngine(workspace_root=root, mode=Mode.INTERACTIVE)
|
|
engine.allow_readonly_for_session()
|
|
return engine
|
|
|
|
|
|
def runs(engine, command: str) -> bool:
|
|
return engine.evaluate("run_shell", {"command": command}, None).allowed
|
|
|
|
|
|
# -- the grant still does the job it was built for --------------------------------
|
|
# Born of ~15 hand-approvals per security-scan run; scoping must not undo that.
|
|
@pytest.mark.parametrize(
|
|
"command",
|
|
[
|
|
"cat build.log",
|
|
"grep AWS_SECRET build.log",
|
|
"head -n 5 build.log",
|
|
"ls src",
|
|
"git status",
|
|
"git log --oneline",
|
|
"pwd",
|
|
"whoami",
|
|
"cat build.log | grep error",
|
|
"find . -name '*.py'",
|
|
],
|
|
)
|
|
def test_reads_inside_the_granted_folder_still_run_unprompted(session, command):
|
|
assert runs(session, command), command
|
|
|
|
|
|
# -- and no longer reads the rest of the machine ----------------------------------
|
|
@pytest.mark.parametrize(
|
|
"command",
|
|
[
|
|
"cat ~/.aws/credentials",
|
|
"strings ~/.ssh/id_rsa",
|
|
"cat /etc/shadow",
|
|
"grep -r AWS_SECRET /",
|
|
"find / -name id_rsa",
|
|
"head -n 5 ~/.aws/credentials",
|
|
],
|
|
)
|
|
def test_reads_outside_every_root_now_ask(session, command):
|
|
assert not runs(session, command), command
|
|
|
|
|
|
def test_openworkers_own_secrets_are_no_longer_readable(session):
|
|
# The floor hard-denies WRITES to this file in every mode and cannot be overridden.
|
|
# Reading was never checked, so one click on a convenience button dumped it into the
|
|
# transcript — and from there to the model provider on the next turn.
|
|
assert not runs(session, "cat ~/.config/coworker/secrets.json")
|
|
assert not runs(session, "jq . ~/.config/coworker/secrets.json")
|
|
|
|
|
|
def test_another_repository_is_not_in_scope(session):
|
|
# `git -C <dir>` is the one accepted way to leave the working directory.
|
|
assert not runs(session, "git -C ~/other-private-repo log -p")
|
|
|
|
|
|
def test_traversal_is_resolved_not_string_matched(session):
|
|
assert not runs(session, "cat ../../../etc/passwd")
|
|
assert not runs(session, "cat ./../../etc/passwd")
|
|
|
|
|
|
def test_a_second_granted_root_is_in_scope(tmp_path):
|
|
from coworker.roots import RootDir
|
|
|
|
repo, notes = tmp_path / "repo", tmp_path / "notes"
|
|
repo.mkdir()
|
|
notes.mkdir()
|
|
(notes / "todo.md").write_text("x", encoding="utf-8")
|
|
engine = PermissionEngine(
|
|
workspace_root=repo,
|
|
mode=Mode.INTERACTIVE,
|
|
roots=[RootDir(path=repo, writable=True), RootDir(path=notes, writable=False)],
|
|
)
|
|
engine.allow_readonly_for_session()
|
|
# Reading needs read access, not write access.
|
|
assert runs(engine, f"cat {notes / 'todo.md'}")
|
|
assert not runs(engine, "cat ~/.aws/credentials")
|
|
|
|
|
|
def test_a_pipeline_is_only_as_scoped_as_its_stages(session):
|
|
assert runs(session, "cat build.log | grep -i error")
|
|
# One out-of-scope stage is enough to stop the whole pipeline.
|
|
assert not runs(session, "cat build.log | grep -f ~/.ssh/id_rsa")
|
|
|
|
|
|
def test_scoping_never_loosens_the_verb_rules(session):
|
|
# The classifier's own rejections stand: a path inside the workspace does not make
|
|
# an executing or networking command acceptable.
|
|
for command in ("python -c 'print(1)'", "curl https://x.io", "pytest -q", "rm build.log"):
|
|
assert not runs(session, command), command
|
|
|
|
|
|
def test_without_the_grant_nothing_changes(tmp_path):
|
|
engine = PermissionEngine(workspace_root=tmp_path, mode=Mode.INTERACTIVE)
|
|
assert not runs(engine, "cat build.log") # still asks — no grant was made
|
|
|
|
|
|
# -- target extraction ------------------------------------------------------------
|
|
@pytest.mark.parametrize(
|
|
"command,expected",
|
|
[
|
|
("cat a.txt b.txt", ["a.txt", "b.txt"]),
|
|
("grep PATTERN a.txt", ["a.txt"]), # the pattern is not a file
|
|
("grep -f patterns.txt a.txt", ["patterns.txt", "a.txt"]), # -f supplies it instead
|
|
("head -n 5 a.txt", ["a.txt"]), # a count is not a file
|
|
("cut -f 1 data.csv", ["data.csv"]), # -f is a FIELD here, not a file
|
|
("git -C /elsewhere log", ["/elsewhere"]),
|
|
("git status", []),
|
|
("echo hello", []),
|
|
("pwd", []),
|
|
("jq .foo data.json", ["data.json"]), # the filter is not a file
|
|
("find /tmp -name x", ["/tmp"]), # predicates end the path list
|
|
("nl a.txt | sed -n 2p", ["a.txt"]),
|
|
],
|
|
)
|
|
def test_read_targets_names_the_files_and_not_the_arguments(command, expected):
|
|
assert read_targets(command) == expected
|
|
|
|
|
|
def test_read_targets_is_empty_for_commands_the_classifier_rejects():
|
|
# Nothing to scope when the command never gets that far.
|
|
assert not is_readonly_command("curl https://x.io > out")
|
|
assert read_targets("curl https://x.io > out") == []
|