mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-12 15:20:19 +00:00
approvals: session read-only command grant; enable on consent card
'Allow read-only commands' on shell cards — fail-closed classifier (local reads + pipelines only; no network/interpreters/writes), session-scoped, persisted with grants. Consent cards get an in-place Enable button.
This commit is contained in:
@@ -31,9 +31,20 @@ class ApprovalOutcome(str, Enum):
|
|||||||
ONCE = "once"
|
ONCE = "once"
|
||||||
ALWAYS_TOOL = "always_tool"
|
ALWAYS_TOOL = "always_tool"
|
||||||
ALWAYS_COMMAND = "always_command"
|
ALWAYS_COMMAND = "always_command"
|
||||||
|
# Session-wide grant for classifier-approved read-only shell commands (readonly.py).
|
||||||
|
READONLY_SESSION = "readonly_session"
|
||||||
DENY = "deny"
|
DENY = "deny"
|
||||||
|
|
||||||
|
|
||||||
|
def _readonly_ok(arguments: dict) -> bool:
|
||||||
|
command = str((arguments or {}).get("command", "") or "")
|
||||||
|
if not command:
|
||||||
|
return False
|
||||||
|
from .readonly import is_readonly_command
|
||||||
|
|
||||||
|
return is_readonly_command(command)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PermissionRequest:
|
class PermissionRequest:
|
||||||
tool_name: str
|
tool_name: str
|
||||||
@@ -711,6 +722,9 @@ class TurnEngine:
|
|||||||
metadata,
|
metadata,
|
||||||
self.permissions.risk_overrides,
|
self.permissions.risk_overrides,
|
||||||
),
|
),
|
||||||
|
# True when this shell command classifies as read-only — the card
|
||||||
|
# offers "Allow read-only commands for this session" only then.
|
||||||
|
"readonly_ok": _readonly_ok(tool_call.arguments),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self._audit(tool_call, stage="approval_requested", reason=decision.reason)
|
self._audit(tool_call, stage="approval_requested", reason=decision.reason)
|
||||||
@@ -745,6 +759,8 @@ class TurnEngine:
|
|||||||
self.permissions.allow_command_for_session(
|
self.permissions.allow_command_for_session(
|
||||||
str(tool_call.arguments.get("command", ""))
|
str(tool_call.arguments.get("command", ""))
|
||||||
)
|
)
|
||||||
|
elif outcome is ApprovalOutcome.READONLY_SESSION:
|
||||||
|
self.permissions.allow_readonly_for_session()
|
||||||
allowed, reason = True, "approved by user"
|
allowed, reason = True, "approved by user"
|
||||||
self._audit(
|
self._audit(
|
||||||
tool_call,
|
tool_call,
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ class PermissionEngine:
|
|||||||
auto_allow_tools: set[str] = field(default_factory=set)
|
auto_allow_tools: set[str] = field(default_factory=set)
|
||||||
session_allow_tools: set[str] = field(default_factory=set)
|
session_allow_tools: set[str] = field(default_factory=set)
|
||||||
session_allow_commands: set[str] = field(default_factory=set)
|
session_allow_commands: set[str] = field(default_factory=set)
|
||||||
|
# Session-wide read-only grant (owner ask 2026-08-11): auto-allow shell commands the
|
||||||
|
# conservative classifier (coworker/readonly.py) accepts. User-elected per session.
|
||||||
|
session_readonly: bool = False
|
||||||
# Task-scoped standing rules (§25): {tool: {allowed targets}}, seeded from the owning
|
# Task-scoped standing rules (§25): {tool: {allowed targets}}, seeded from the owning
|
||||||
# ScheduledTask's target-shaped entries. Kept by reference and re-read every check, so a
|
# ScheduledTask's target-shaped entries. Kept by reference and re-read every check, so a
|
||||||
# rule minted mid-run ("Allow every time") applies to the run's next call too.
|
# rule minted mid-run ("Allow every time") applies to the run's next call too.
|
||||||
@@ -154,6 +157,11 @@ class PermissionEngine:
|
|||||||
return Decision(True, "command on allowlist")
|
return Decision(True, "command on allowlist")
|
||||||
if command and command in self.session_allow_commands:
|
if command and command in self.session_allow_commands:
|
||||||
return Decision(True, "command allowed for session")
|
return Decision(True, "command allowed for session")
|
||||||
|
if self.session_readonly and command:
|
||||||
|
from .readonly import is_readonly_command
|
||||||
|
|
||||||
|
if is_readonly_command(command):
|
||||||
|
return Decision(True, "read-only command (session grant)")
|
||||||
if tool_name in self.session_allow_tools and not is_connector:
|
if tool_name in self.session_allow_tools and not is_connector:
|
||||||
return Decision(True, "tool allowed for session")
|
return Decision(True, "tool allowed for session")
|
||||||
|
|
||||||
@@ -185,6 +193,9 @@ class PermissionEngine:
|
|||||||
if command:
|
if command:
|
||||||
self.session_allow_commands.add(command)
|
self.session_allow_commands.add(command)
|
||||||
|
|
||||||
|
def allow_readonly_for_session(self) -> None:
|
||||||
|
self.session_readonly = True
|
||||||
|
|
||||||
# -- helpers ----------------------------------------------------------------
|
# -- helpers ----------------------------------------------------------------
|
||||||
def _candidate(self, path: str) -> Path:
|
def _candidate(self, path: str) -> Path:
|
||||||
# Relative paths resolve against the primary (workspace_root); absolute/`~` taken as-is.
|
# Relative paths resolve against the primary (workspace_root); absolute/`~` taken as-is.
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Conservative read-only shell-command classifier for the session-scoped grant.
|
||||||
|
|
||||||
|
"Allow read-only commands for this session" (owner ask 2026-08-11, born of approval
|
||||||
|
fatigue in security-scan sessions: ~15 hand-approvals per run) auto-allows a command only
|
||||||
|
when THIS classifier accepts it. The contract:
|
||||||
|
|
||||||
|
- **Local filesystem reads only.** Network clients (curl/wget/ssh/nc) are deliberately
|
||||||
|
excluded even for GET — an auto-allowed network command is an exfiltration channel
|
||||||
|
under prompt injection. Interpreters (python/ruby/sh -c) and anything that can write,
|
||||||
|
execute, or mutate are excluded.
|
||||||
|
- **Pipelines are allowed** (`nl … | sed -n … | grep …`) — every stage must classify.
|
||||||
|
All other shell operators (;, &&, ||, &, redirections, substitutions) are rejected
|
||||||
|
outright.
|
||||||
|
- **Fail closed.** Unknown commands, unparseable input, path-invoked binaries, and any
|
||||||
|
doubtful flag reject. False negatives cost one manual approval; false positives cost
|
||||||
|
an unreviewed side effect — the asymmetry decides every edge case here.
|
||||||
|
|
||||||
|
This is a user-elected convenience on top of the approval flow, not a sandbox: the
|
||||||
|
session still runs under its permission mode, and the user granted the scope explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
# Commands that only read local state, with no writing flags to police.
|
||||||
|
_SIMPLE_SAFE = {
|
||||||
|
"ls", "cat", "head", "tail", "wc", "nl", "sort", "uniq", "cut", "tr",
|
||||||
|
"grep", "egrep", "fgrep", "rg", "ugrep", "file", "stat", "du", "df",
|
||||||
|
"pwd", "echo", "printf", "which", "whoami", "id", "date", "uname",
|
||||||
|
"basename", "dirname", "realpath", "readlink", "jq", "column", "diff",
|
||||||
|
"comm", "strings", "md5sum", "shasum", "sha1sum", "sha256sum",
|
||||||
|
"hexdump", "xxd", "od", "true", "false", "yamllint", "actionlint",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Git subcommands that only read. Note the per-subcommand guards below — several git
|
||||||
|
# "read" commands grow write/exec behavior through specific flags.
|
||||||
|
_GIT_SAFE = {
|
||||||
|
"status", "log", "show", "diff", "blame", "shortlog", "describe",
|
||||||
|
"rev-parse", "rev-list", "ls-files", "ls-tree", "grep", "cat-file",
|
||||||
|
"name-rev", "merge-base", "count-objects", "var", "check-ignore",
|
||||||
|
}
|
||||||
|
|
||||||
|
_GIT_BRANCH_FLAG_OK = {
|
||||||
|
"--show-current", "--list", "-a", "-r", "-v", "-vv", "--contains",
|
||||||
|
"--merged", "--no-merged", "--all",
|
||||||
|
}
|
||||||
|
|
||||||
|
_FIND_BAD = ("-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fls", "-fprintf")
|
||||||
|
|
||||||
|
_ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=[^;&|<>`]*$")
|
||||||
|
|
||||||
|
# A sed script token that invokes the `w`/`W` (write-file) command: at the start, after a
|
||||||
|
# separator, or after an address. Conservative — a false hit just means one manual approval.
|
||||||
|
_SED_WRITE = re.compile(r"(^|[;{])\s*[0-9,$/ ]*[wW]\s")
|
||||||
|
|
||||||
|
|
||||||
|
def _stages(command: str) -> list[list[str]] | None:
|
||||||
|
"""Tokenize with operators surfaced; split into pipeline stages. None = reject."""
|
||||||
|
if not command or not command.strip():
|
||||||
|
return None
|
||||||
|
# Substitutions can hide inside double quotes, which the tokenizer strips — check the
|
||||||
|
# raw text. Rejects a literal '$(' in a grep pattern too; that asymmetry is the point.
|
||||||
|
if "`" in command or "$(" in command or "<(" in command or ">(" in command:
|
||||||
|
return None
|
||||||
|
lex = shlex.shlex(command, posix=True, punctuation_chars=True)
|
||||||
|
lex.whitespace_split = True
|
||||||
|
try:
|
||||||
|
tokens = list(lex)
|
||||||
|
except ValueError:
|
||||||
|
return None # unbalanced quotes etc.
|
||||||
|
stages: list[list[str]] = [[]]
|
||||||
|
for tok in tokens:
|
||||||
|
if tok == "|":
|
||||||
|
stages.append([])
|
||||||
|
elif tok in {";", "&", "&&", "||", "|&"} or (tok and set(tok) <= {">", "<", "&", "0", "1", "2"} and any(c in tok for c in "<>&")):
|
||||||
|
return None # every operator except a plain pipe rejects (incl. 2>, &>, <<)
|
||||||
|
else:
|
||||||
|
stages[-1].append(tok)
|
||||||
|
if any(not s for s in stages):
|
||||||
|
return None # empty stage ("| cmd", "cmd |")
|
||||||
|
return stages
|
||||||
|
|
||||||
|
|
||||||
|
def _git_ok(args: list[str]) -> bool:
|
||||||
|
# Global flags: only `-C <dir>` and `--no-pager` pass; `-c`/`--config-env` can set
|
||||||
|
# core.pager and similar exec hooks — rejected.
|
||||||
|
i = 0
|
||||||
|
while i < len(args):
|
||||||
|
if args[i] == "-C" and i + 1 < len(args):
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if args[i] == "--no-pager":
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
if i >= len(args):
|
||||||
|
return False
|
||||||
|
sub, rest = args[i], args[i + 1 :]
|
||||||
|
if any(t.startswith("--output") for t in rest):
|
||||||
|
return False # git log/diff --output=<file> writes
|
||||||
|
if sub in _GIT_SAFE:
|
||||||
|
return True
|
||||||
|
if sub == "branch":
|
||||||
|
return all(t in _GIT_BRANCH_FLAG_OK or t.startswith(("--format=", "--sort=")) for t in rest)
|
||||||
|
if sub == "tag":
|
||||||
|
return bool(rest) and all(
|
||||||
|
t in {"-l", "--list", "-n", "--contains", "--merged"} or t.startswith("-n") for t in rest
|
||||||
|
)
|
||||||
|
if sub == "stash":
|
||||||
|
return bool(rest) and rest[0] in {"list", "show"}
|
||||||
|
if sub == "remote":
|
||||||
|
return not rest or rest[0] in {"-v", "show", "get-url"}
|
||||||
|
if sub == "config":
|
||||||
|
return any(t in {"--get", "--get-all", "--get-regexp", "--list", "-l"} for t in rest)
|
||||||
|
if sub == "reflog":
|
||||||
|
return not rest or rest[0] == "show"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_ok(argv: list[str]) -> bool:
|
||||||
|
# Leading VAR=value assignments (LC_ALL=C grep …) are inert — skip them.
|
||||||
|
i = 0
|
||||||
|
while i < len(argv) and _ENV_ASSIGN.match(argv[i]):
|
||||||
|
i += 1
|
||||||
|
argv = argv[i:]
|
||||||
|
if not argv:
|
||||||
|
return False
|
||||||
|
head = argv[0]
|
||||||
|
if "/" in head:
|
||||||
|
return False # path-invoked binaries can be anything; bare names only
|
||||||
|
args = argv[1:]
|
||||||
|
if head in _SIMPLE_SAFE:
|
||||||
|
return True
|
||||||
|
if head == "env":
|
||||||
|
return not args # bare `env` prints; `env CMD` executes
|
||||||
|
if head == "command":
|
||||||
|
return bool(args) and args[0] in {"-v", "-V"}
|
||||||
|
if head == "git":
|
||||||
|
return _git_ok(args)
|
||||||
|
if head == "sed":
|
||||||
|
if any(t.startswith(("-i", "--in-place", "-f", "--file")) for t in args):
|
||||||
|
return False
|
||||||
|
return not any(_SED_WRITE.search(t) for t in args if not t.startswith("-"))
|
||||||
|
if head in {"awk", "gawk", "mawk", "nawk"}:
|
||||||
|
return not any(">" in t or "system" in t for t in args)
|
||||||
|
if head == "find":
|
||||||
|
return not any(t.startswith(_FIND_BAD) for t in args)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_readonly_command(command: str) -> bool:
|
||||||
|
"""True iff `command` is a single command or pure pipeline of local read-only stages."""
|
||||||
|
stages = _stages(str(command or ""))
|
||||||
|
if stages is None:
|
||||||
|
return False
|
||||||
|
return all(_stage_ok(s) for s in stages)
|
||||||
@@ -98,7 +98,13 @@ def _grants_of(engine) -> dict[str, Any]:
|
|||||||
"""The engine's session-scoped "Always allow" approvals, in persistable shape."""
|
"""The engine's session-scoped "Always allow" approvals, in persistable shape."""
|
||||||
tools = sorted(getattr(engine.permissions, "session_allow_tools", None) or ())
|
tools = sorted(getattr(engine.permissions, "session_allow_tools", None) or ())
|
||||||
commands = sorted(getattr(engine.permissions, "session_allow_commands", None) or ())
|
commands = sorted(getattr(engine.permissions, "session_allow_commands", None) or ())
|
||||||
return {"tools": tools, "commands": commands} if (tools or commands) else {}
|
readonly = bool(getattr(engine.permissions, "session_readonly", False))
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
if tools or commands or readonly:
|
||||||
|
out = {"tools": tools, "commands": commands}
|
||||||
|
if readonly:
|
||||||
|
out["readonly"] = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _approval_body(request) -> str:
|
def _approval_body(request) -> str:
|
||||||
@@ -3525,6 +3531,8 @@ class SessionManager:
|
|||||||
engine.permissions.allow_tool_for_session(str(tool))
|
engine.permissions.allow_tool_for_session(str(tool))
|
||||||
for command in grants.get("commands") or []:
|
for command in grants.get("commands") or []:
|
||||||
engine.permissions.allow_command_for_session(str(command))
|
engine.permissions.allow_command_for_session(str(command))
|
||||||
|
if grants.get("readonly"):
|
||||||
|
engine.permissions.allow_readonly_for_session()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extra_roots_of(engine: TurnEngine) -> list[dict[str, Any]]:
|
def _extra_roots_of(engine: TurnEngine) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -78,3 +78,21 @@ test("a one-paragraph digest send is clamped to a card, expandable in place", as
|
|||||||
expect((await prev.boundingBox())!.height).toBeGreaterThan(clampedHeight);
|
expect((await prev.boundingBox())!.height).toBeGreaterThan(clampedHeight);
|
||||||
await expect(prev.getByText("show less")).toBeVisible();
|
await expect(prev.getByText("show less")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("read-only session grant: offered on classified commands, resolves the card", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||||
|
await box.fill("please run a tool");
|
||||||
|
await page.getByRole("button", { name: "Send" }).click();
|
||||||
|
|
||||||
|
// The mocked `ls` proposal carries readonly_ok → the session-wide grant is offered.
|
||||||
|
const btn = page.getByTestId("allow-readonly-session");
|
||||||
|
await expect(btn).toBeVisible();
|
||||||
|
await expect(btn).toHaveAttribute("title", /no network, writes, or interpreters/);
|
||||||
|
await btn.click();
|
||||||
|
|
||||||
|
// Grant approves the pending call; the turn proceeds like any approval.
|
||||||
|
await expect(page.getByText(/The command ran; 1 file found/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -606,6 +606,7 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
name: "run_shell",
|
name: "run_shell",
|
||||||
arguments: { command: "ls" },
|
arguments: { command: "ls" },
|
||||||
reason: "The coworker wants to run a command.",
|
reason: "The coworker wants to run a command.",
|
||||||
|
readonly_ok: true, // `ls` classifies read-only server-side
|
||||||
});
|
});
|
||||||
return; // suspended on the approval
|
return; // suspended on the approval
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,13 @@ test("zip import: trust warning leads, tools collapse behind a chevron, replaces
|
|||||||
await expect(card.getByTestId("replaces-note")).toContainText("MORE capabilities");
|
await expect(card.getByTestId("replaces-note")).toContainText("MORE capabilities");
|
||||||
await expect(card.getByText(/github.*(recommended).*open fix PRs/)).toBeVisible();
|
await expect(card.getByText(/github.*(recommended).*open fix PRs/)).toBeVisible();
|
||||||
|
|
||||||
// Imported coworker landed disabled in the list above, pending consent.
|
// Imported coworker landed disabled in the list above, pending consent —
|
||||||
|
// and the card itself carries the Enable action (no hunting back up the list).
|
||||||
const row = page.locator(".divide-y > div").filter({ hasText: "Team Security Coworker" });
|
const row = page.locator(".divide-y > div").filter({ hasText: "Team Security Coworker" });
|
||||||
await expect(row.getByRole("checkbox", { name: "Enabled" })).not.toBeChecked();
|
await expect(row.getByRole("checkbox", { name: "Enabled" })).not.toBeChecked();
|
||||||
|
await card.getByTestId("consent-enable-team-sec").click();
|
||||||
|
await expect(card.getByTestId("consent-enabled")).toContainText("it's in your coworker picker");
|
||||||
|
await expect(row.getByRole("checkbox", { name: "Enabled" })).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Export… zips an installed coworker's bundle to a chosen folder", async ({ page }) => {
|
test("Export… zips an installed coworker's bundle to a chosen folder", async ({ page }) => {
|
||||||
|
|||||||
@@ -710,6 +710,7 @@ export function App() {
|
|||||||
reason: d.reason,
|
reason: d.reason,
|
||||||
category: d.category,
|
category: d.category,
|
||||||
standingTarget: d.standing_target || undefined,
|
standingTarget: d.standing_target || undefined,
|
||||||
|
readonlyOk: !!d.readonly_ok,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -304,3 +304,33 @@ describe("InboxItemCard — parked save_skill proposals (SKILLS-SPEC §5.2)", ()
|
|||||||
expect(onResolve).toHaveBeenCalledWith("i9", "deny");
|
expect(onResolve).toHaveBeenCalledWith("i9", "deny");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ApprovalCard — session read-only grant", () => {
|
||||||
|
const shellApproval = (extra: Partial<ApprovalItem> = {}): ApprovalItem => ({
|
||||||
|
kind: "approval",
|
||||||
|
name: "run_shell",
|
||||||
|
args: { command: "ls -la" },
|
||||||
|
reason: "requires approval",
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the read-only session grant only when the server classified the command read-only", () => {
|
||||||
|
const onApprove = vi.fn();
|
||||||
|
render(<ApprovalCard item={shellApproval({ readonlyOk: true })} onApprove={onApprove} />);
|
||||||
|
fireEvent.click(screen.getByTestId("allow-readonly-session"));
|
||||||
|
expect(onApprove).toHaveBeenCalledWith("readonly_session");
|
||||||
|
// The command-scoped grant stays alongside — different scopes, both legitimate.
|
||||||
|
expect(screen.getByText("Always allow this command")).toBeTruthy();
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
// Not classified read-only (a write) → the button never renders.
|
||||||
|
const onApprove2 = vi.fn();
|
||||||
|
render(
|
||||||
|
<ApprovalCard
|
||||||
|
item={shellApproval({ args: { command: "rm -rf x" }, readonlyOk: false })}
|
||||||
|
onApprove={onApprove2}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("allow-readonly-session")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -210,6 +210,20 @@ function Buttons({
|
|||||||
Always allow this command
|
Always allow this command
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Session-wide read-only grant (owner ask 2026-08-11): offered only when the
|
||||||
|
server's conservative classifier accepted THIS command — one click, then every
|
||||||
|
local-read command in the session runs without a card. Network, writes, and
|
||||||
|
anything doubtful keep asking. */}
|
||||||
|
{item.name === "run_shell" && item.readonlyOk && !item.resolved && (
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
data-testid="allow-readonly-session"
|
||||||
|
title="Auto-allow read-only commands (local reads and pipelines only — no network, writes, or interpreters) for the rest of this session"
|
||||||
|
onClick={() => onApprove("readonly_session")}
|
||||||
|
>
|
||||||
|
Allow read-only commands
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button className="btn quiet-deny" onClick={() => onApprove("deny")}>
|
<button className="btn quiet-deny" onClick={() => onApprove("deny")}>
|
||||||
{denyLabel}
|
{denyLabel}
|
||||||
|
|||||||
@@ -318,7 +318,14 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{consent.map((c) => (
|
{consent.map((c) => (
|
||||||
<ConsentCard key={c.id} c={c} />
|
<ConsentCard
|
||||||
|
key={c.id}
|
||||||
|
c={c}
|
||||||
|
enabled={personas.find((p) => p.id === c.id)?.enabled ?? false}
|
||||||
|
onEnable={async () => {
|
||||||
|
await toggle(c.id, { enabled: true, surfaced: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -336,8 +343,17 @@ const RISK_PHRASE: Record<string, string> = {
|
|||||||
write_remote: "act on connected services",
|
write_remote: "act on connected services",
|
||||||
};
|
};
|
||||||
|
|
||||||
function ConsentCard({ c }: { c: PersonaConsent }) {
|
function ConsentCard({
|
||||||
|
c,
|
||||||
|
enabled,
|
||||||
|
onEnable,
|
||||||
|
}: {
|
||||||
|
c: PersonaConsent;
|
||||||
|
enabled: boolean;
|
||||||
|
onEnable: () => Promise<void>;
|
||||||
|
}) {
|
||||||
const [showTools, setShowTools] = useState(false);
|
const [showTools, setShowTools] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
const phrases = (c.risk.length ? c.risk : ["read"]).map((r) => RISK_PHRASE[r] || r);
|
const phrases = (c.risk.length ? c.risk : ["read"]).map((r) => RISK_PHRASE[r] || r);
|
||||||
const summary = phrases.join(", ").replace(/, ([^,]*)$/, " and $1");
|
const summary = phrases.join(", ").replace(/, ([^,]*)$/, " and $1");
|
||||||
const recommends = c.recommends || [];
|
const recommends = c.recommends || [];
|
||||||
@@ -384,8 +400,27 @@ function ConsentCard({ c }: { c: PersonaConsent }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-[12px] text-faint mt-2">
|
<div className="flex items-center gap-3 mt-2.5">
|
||||||
Recommended mode: {c.recommended_mode}. Enable it above to use it.
|
{/* Enable right here (owner ask 2026-08-11) — the old "enable it above" copy
|
||||||
|
sent the user hunting back up the list. */}
|
||||||
|
{enabled ? (
|
||||||
|
<span className="text-[12.5px] text-muted" data-testid="consent-enabled">
|
||||||
|
✓ Enabled — it's in your coworker picker.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={BTN_ACCENT}
|
||||||
|
data-testid={`consent-enable-${c.id}`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setBusy(true);
|
||||||
|
void onEnable().finally(() => setBusy(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "Enabling…" : "Enable this coworker"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-[12px] text-faint">Recommended mode: {c.recommended_mode}.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import type { MessageSource } from "./api";
|
|||||||
|
|
||||||
// "always_task" persists to the owning automation's task record (standing scoped
|
// "always_task" persists to the owning automation's task record (standing scoped
|
||||||
// approval, UX-DECISIONS §25) — offered only on automation-run approval cards, in-app.
|
// approval, UX-DECISIONS §25) — offered only on automation-run approval cards, in-app.
|
||||||
export type ApprovalDecision = "once" | "deny" | "always_tool" | "always_command" | "always_task";
|
export type ApprovalDecision = "once" | "deny" | "always_tool" | "always_command" | "always_task" | "readonly_session";
|
||||||
|
|
||||||
export interface TodoItem {
|
export interface TodoItem {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -116,6 +116,9 @@ export type Item =
|
|||||||
// The exact target a standing rule could pin (server-computed) — with a run
|
// The exact target a standing rule could pin (server-computed) — with a run
|
||||||
// context, the card offers "Allow every time" (§25).
|
// context, the card offers "Allow every time" (§25).
|
||||||
standingTarget?: string;
|
standingTarget?: string;
|
||||||
|
// Server-classified: this shell command only reads locally, so the card may offer
|
||||||
|
// the session-wide "Allow read-only commands" grant.
|
||||||
|
readonlyOk?: boolean;
|
||||||
resolved?: ApprovalDecision;
|
resolved?: ApprovalDecision;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""The session-scoped read-only command grant (owner ask 2026-08-11).
|
||||||
|
|
||||||
|
The classifier is deliberately fail-closed: local reads and pure pipelines only. A false
|
||||||
|
negative costs one manual approval; a false positive costs an unreviewed side effect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from coworker.permissions import Mode, PermissionEngine
|
||||||
|
from coworker.readonly import is_readonly_command
|
||||||
|
|
||||||
|
ACCEPT = [
|
||||||
|
"ls -la",
|
||||||
|
"cat README.md",
|
||||||
|
"grep -rn 'pattern' src",
|
||||||
|
"rg --json TODO",
|
||||||
|
"nl -ba tests/test_x.py | sed -n '320,345p'",
|
||||||
|
"nl -ba a.py | sed -E \"s/'[^']*'/'[REDACTED]'/g\"",
|
||||||
|
"git log --oneline -5",
|
||||||
|
"git diff main...HEAD",
|
||||||
|
"git status",
|
||||||
|
"git -C /tmp/repo log -1",
|
||||||
|
"git branch --show-current",
|
||||||
|
"git stash list",
|
||||||
|
"git config --get user.name",
|
||||||
|
"git remote -v",
|
||||||
|
"jq '.results | length' /tmp/report.json",
|
||||||
|
"find . -name '*.py'",
|
||||||
|
"LC_ALL=C grep -c uses .github/workflows/ci.yml",
|
||||||
|
"command -v semgrep",
|
||||||
|
"wc -l file.txt | sort",
|
||||||
|
"printf 'a: '",
|
||||||
|
"awk '{print $1}' data.txt",
|
||||||
|
"head -20 x | tail -5 | uniq -c",
|
||||||
|
]
|
||||||
|
|
||||||
|
REJECT = [
|
||||||
|
"",
|
||||||
|
"rm -rf /",
|
||||||
|
"cat a > b", # redirection
|
||||||
|
"cat a >> b",
|
||||||
|
"grep x f 2>/dev/null", # even stderr redirects
|
||||||
|
"ls; rm -rf /", # chaining
|
||||||
|
"ls && touch x",
|
||||||
|
"cat `whoami`", # substitution
|
||||||
|
"cat $(secret)",
|
||||||
|
"grep '$(x)' file", # can't tell quoted-safe apart — fail closed
|
||||||
|
"curl https://api.github.com/repos/x", # network = exfil channel, excluded
|
||||||
|
"wget http://x",
|
||||||
|
"ssh host ls",
|
||||||
|
"python3 -c 'print(1)'", # interpreters
|
||||||
|
"bash -c ls",
|
||||||
|
"sed -i 's/a/b/' f", # in-place write
|
||||||
|
"sed -n 'w /tmp/x' f", # sed write command
|
||||||
|
"sed -f script.sed f", # script file could carry w
|
||||||
|
"awk '{print > \"f\"}' x", # awk redirection
|
||||||
|
"awk 'BEGIN{system(\"rm x\")}'",
|
||||||
|
"find . -delete",
|
||||||
|
"find . -exec rm {} ;",
|
||||||
|
"git push origin main",
|
||||||
|
"git branch new-branch", # creates
|
||||||
|
"git tag v1", # creates
|
||||||
|
"git stash", # writes
|
||||||
|
"git config user.name evil", # writes
|
||||||
|
"git -c core.pager='touch x' log", # exec hook via -c
|
||||||
|
"git log --output=/tmp/f", # write via flag
|
||||||
|
"/tmp/evil/cat file", # path-invoked binary
|
||||||
|
"env FOO=1 rm x",
|
||||||
|
"tee /tmp/x",
|
||||||
|
"xargs rm",
|
||||||
|
"ls | tee /tmp/x", # every pipeline stage must classify
|
||||||
|
"ls |", # dangling pipe
|
||||||
|
"sudo cat /etc/shadow",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cmd", ACCEPT)
|
||||||
|
def test_classifier_accepts(cmd):
|
||||||
|
assert is_readonly_command(cmd) is True, cmd
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cmd", REJECT)
|
||||||
|
def test_classifier_rejects(cmd):
|
||||||
|
assert is_readonly_command(cmd) is False, cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_grant_gates_on_classifier(tmp_path):
|
||||||
|
eng = PermissionEngine(workspace_root=tmp_path, mode=Mode.INTERACTIVE)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
category = "shell"
|
||||||
|
risk_level = "high"
|
||||||
|
capabilities = ["exec"]
|
||||||
|
|
||||||
|
# Before the grant: a read-only command still asks.
|
||||||
|
d = eng.evaluate("run_shell", {"command": "ls -la"}, Meta())
|
||||||
|
assert d.needs_user
|
||||||
|
|
||||||
|
eng.allow_readonly_for_session()
|
||||||
|
assert eng.evaluate("run_shell", {"command": "ls -la"}, Meta()).allowed
|
||||||
|
assert eng.evaluate("run_shell", {"command": "git log -1"}, Meta()).allowed
|
||||||
|
# The grant never covers writes/network — those keep asking.
|
||||||
|
assert eng.evaluate("run_shell", {"command": "rm -rf x"}, Meta()).needs_user
|
||||||
|
assert eng.evaluate("run_shell", {"command": "curl https://x"}, Meta()).needs_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_grant_persists_via_session_grants(tmp_path):
|
||||||
|
from coworker.server.manager import _grants_of
|
||||||
|
|
||||||
|
class FakeEngine:
|
||||||
|
class permissions:
|
||||||
|
session_allow_tools = set()
|
||||||
|
session_allow_commands = set()
|
||||||
|
session_readonly = True
|
||||||
|
|
||||||
|
grants = _grants_of(FakeEngine)
|
||||||
|
assert grants == {"tools": [], "commands": [], "readonly": True}
|
||||||
Reference in New Issue
Block a user