mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-05 00:56:40 +00:00
tools: give coworkers the user's real toolchain, and stop silent skips
Sidecar inherits the login shell's env; toolchain resolves absolute paths with pinned installs; request_tool replaces the 'tool missing -> STOP' instruction that hid a check.
This commit is contained in:
@@ -41,6 +41,7 @@ from .tools import ToolRegistry
|
||||
from .tools.ask import ask_user_tool
|
||||
from .tools.directories import request_directory_tool
|
||||
from .tools.plan import propose_plan_tool
|
||||
from .tools.toolreq import request_tool_tool
|
||||
from .tools.subagent import explorer_tools
|
||||
from .web import make_web_fetch_tool, make_web_search_tool
|
||||
from .workspace_trust import WorkspaceTrustStore
|
||||
@@ -211,6 +212,7 @@ def build_engine(
|
||||
directory_requester: Optional[Any] = None,
|
||||
plan_approver: Optional[Any] = None,
|
||||
question_asker: Optional[Any] = None,
|
||||
tool_requester: Optional[Any] = None,
|
||||
subscription_store: Optional[Any] = None,
|
||||
channel_buffer: Optional[Any] = None,
|
||||
routing_targets: Optional[list[str]] = None,
|
||||
@@ -274,6 +276,10 @@ def build_engine(
|
||||
# Knowledge surfaces with a multi-root workspace can ask the user mid-task for another folder.
|
||||
if agent.family == "knowledge" and root_list:
|
||||
registry.register(request_directory_tool())
|
||||
# Anything with a shell can hit a missing CLI (a scanner, aws, kubectl). Give it a way to
|
||||
# ask instead of silently dropping the check that needed it (OPE-85).
|
||||
if executor is not None:
|
||||
registry.register(request_tool_tool())
|
||||
if agent.connectors:
|
||||
enabled_connectors, enabled_tools = _enabled_connector_tools(secrets)
|
||||
# Per-session connection hierarchy (UI-REFRESH §4.3): when the caller supplies the session's
|
||||
@@ -489,6 +495,7 @@ def build_engine(
|
||||
directory_requester=directory_requester,
|
||||
plan_approver=plan_approver,
|
||||
question_asker=question_asker,
|
||||
tool_requester=tool_requester,
|
||||
)
|
||||
engine.executor = executor # type: ignore[attr-defined]
|
||||
engine.todo = todo # type: ignore[attr-defined]
|
||||
|
||||
@@ -85,6 +85,9 @@ class TurnEngine:
|
||||
question_asker: Optional[
|
||||
Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"]
|
||||
] = None,
|
||||
tool_requester: Optional[
|
||||
Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"]
|
||||
] = None,
|
||||
# Called (thread-safe, best-effort) when the user stops the turn — e.g. the
|
||||
# executor's kill for a running shell command.
|
||||
interrupt_hooks: Optional[list[Callable[[], None]]] = None,
|
||||
@@ -107,6 +110,10 @@ class TurnEngine:
|
||||
# user to grant/decline a folder out-of-band, applies the grant to this live session, and
|
||||
# returns the outcome. None on surfaces that can't prompt (the tool then no-ops).
|
||||
self.directory_requester = directory_requester
|
||||
# Handles the `request_tool` tool: emits TOOL_REQUESTED, waits for the user to install
|
||||
# the pinned build or decline. None on surfaces that can't prompt (the tool then
|
||||
# no-ops, and the agent is told so it can fall back openly rather than skip silently).
|
||||
self.tool_requester = tool_requester
|
||||
# Handles the `propose_plan` tool: emits PLAN_PROPOSED, waits for the user's decision.
|
||||
# An approving result flips the live PermissionEngine out of plan mode (same session,
|
||||
# context kept). None on surfaces that can't prompt (the tool then no-ops).
|
||||
@@ -616,6 +623,10 @@ class TurnEngine:
|
||||
async for event in self._handle_directory_request(tool_call):
|
||||
yield event
|
||||
continue
|
||||
if tool_call.name == "request_tool":
|
||||
async for event in self._handle_tool_request(tool_call):
|
||||
yield event
|
||||
continue
|
||||
if tool_call.name == "propose_plan":
|
||||
async for event in self._handle_plan_proposal(tool_call):
|
||||
yield event
|
||||
@@ -931,6 +942,58 @@ class TurnEngine:
|
||||
},
|
||||
)
|
||||
|
||||
async def _handle_tool_request(self, tool_call: ToolCall) -> AsyncIterator[Event]:
|
||||
"""Emit the install prompt, await the user's decision, hand the outcome back.
|
||||
|
||||
Declining is a normal outcome, not an error: the result tells the agent to fall back
|
||||
and disclose the gap, because a security report that quietly loses a check is worse
|
||||
than one that says which checks it couldn't run.
|
||||
"""
|
||||
args = tool_call.arguments or {}
|
||||
name = str(args.get("name", "")).strip()
|
||||
reason = str(args.get("reason", ""))
|
||||
|
||||
if self.tool_requester is None or not name:
|
||||
result: dict[str, Any] = {
|
||||
"installed": False,
|
||||
"error": "tool requests aren't available here",
|
||||
"guidance": (
|
||||
"Continue without it: use a fallback check if you have one, and say in "
|
||||
"your report which checks were degraded."
|
||||
),
|
||||
}
|
||||
else:
|
||||
yield Event(EventType.TOOL_REQUESTED, {"name": name, "reason": reason})
|
||||
self._audit(tool_call, stage="tool_requested", reason=reason)
|
||||
result = await self._interruptible(
|
||||
self.tool_requester(dict(args), tool_call.id),
|
||||
interrupted={"installed": False, "error": "interrupted by user"},
|
||||
) or {"installed": False, "error": "no response"}
|
||||
if not result.get("installed"):
|
||||
result.setdefault(
|
||||
"guidance",
|
||||
"Continue without it: use a fallback check if you have one, and say in "
|
||||
"your report which checks were degraded.",
|
||||
)
|
||||
|
||||
status = "ok" if result.get("installed") else "denied"
|
||||
self.messages.append(_tool_result_message(tool_call, result))
|
||||
self._audit(
|
||||
tool_call,
|
||||
stage="finished",
|
||||
status=status,
|
||||
result=result,
|
||||
result_preview=_preview(result),
|
||||
)
|
||||
yield Event(
|
||||
EventType.TOOL_FINISHED,
|
||||
{
|
||||
"name": tool_call.name,
|
||||
"status": status,
|
||||
"result_preview": _preview(result),
|
||||
},
|
||||
)
|
||||
|
||||
async def _handle_directory_request(
|
||||
self, tool_call: ToolCall
|
||||
) -> AsyncIterator[Event]:
|
||||
|
||||
@@ -19,6 +19,7 @@ class EventType(str, Enum):
|
||||
TOOL_PROPOSED = "tool_proposed"
|
||||
PERMISSION_REQUIRED = "permission_required"
|
||||
DIRECTORY_REQUESTED = "directory_requested" # agent asks the user to grant a folder
|
||||
TOOL_REQUESTED = "tool_requested" # agent asks for a missing CLI tool (scanner, etc.)
|
||||
QUESTION_REQUESTED = (
|
||||
"question_requested" # agent asks the user a free-text/multiple-choice question
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ KIND_QUESTION = "question"
|
||||
KIND_NOTIFICATION = "notification"
|
||||
KIND_DIRECTORY = "directory" # agent asks to be granted a folder
|
||||
KIND_PLAN = "plan" # agent presents a plan for approval
|
||||
KIND_TOOL = "tool" # agent asks for a missing CLI tool to be installed
|
||||
|
||||
STATE_PENDING = "pending"
|
||||
STATE_RESOLVED = "resolved"
|
||||
@@ -269,6 +270,28 @@ class InboxStore:
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
|
||||
def add_tool_request(
|
||||
self,
|
||||
session_id,
|
||||
title,
|
||||
*,
|
||||
body="",
|
||||
inbox="default",
|
||||
visibility=VIS_INBOX,
|
||||
data=None,
|
||||
tool_call_id=None,
|
||||
) -> InboxItem:
|
||||
return self.add(
|
||||
session_id,
|
||||
KIND_TOOL,
|
||||
title,
|
||||
body=body,
|
||||
inbox=inbox,
|
||||
visibility=visibility,
|
||||
data=data,
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
|
||||
def add_notification(
|
||||
self, session_id, title, *, body="", inbox="default", visibility=VIS_INBOX
|
||||
) -> InboxItem:
|
||||
|
||||
@@ -41,6 +41,17 @@ Operate safely:
|
||||
current — the Progress panel is rendered from it.
|
||||
- Scanners run read-only; installing one is a visible, approved step — check availability
|
||||
first and tell the user what's missing rather than failing silently.
|
||||
- NEVER silently skip a check because its tool is missing. A check either RUNS, or it is
|
||||
REPORTED as not run, with the reason. Three options when a tool is absent, in order:
|
||||
ask for it with `request_tool`; fall back to a manual equivalent and say you did; or
|
||||
state plainly that the check was skipped and what that leaves uncovered. Dropping a
|
||||
check quietly turns "we couldn't look" into "nothing there" — the worst outcome a
|
||||
security report can produce.
|
||||
- Every review ends with a short **Coverage** note: which checks ran, which tool ran
|
||||
them, and which were degraded or skipped. Specifically: if gitleaks is unavailable, do
|
||||
the secret sweep yourself over the working tree AND the history (`git log -p`, and the
|
||||
contents of any deleted env/config files) — a secret removed from HEAD but alive in
|
||||
history is exactly what this check exists to catch.
|
||||
- NEVER inline multi-line scripts in shell commands: write a file, then run it.
|
||||
- Secrets are radioactive: never print a discovered secret's value anywhere — not in
|
||||
output, notes, commits, or PRs. Refer to it by location and kind only.
|
||||
|
||||
@@ -8,11 +8,21 @@ further yourself.
|
||||
ABSOLUTE RULE: never print a secret's value — not in output, notes, todo items, commits,
|
||||
or PRs. Refer to every hit as "<kind> in <file>:<line> (commit <short-sha>)".
|
||||
|
||||
1. Check the tool: `gitleaks version`. If missing, tell the user how to install it
|
||||
(`brew install gitleaks`) and STOP — ask before installing anything.
|
||||
2. Scan working tree AND history:
|
||||
`gitleaks detect --source . --report-format json --report-path /tmp/gitleaks.json`
|
||||
(history matters: a secret deleted in HEAD is still live in every clone).
|
||||
1. Check the tool: `gitleaks version`. If it's missing, do NOT skip this scan and do not
|
||||
stop the review — ask for it with `request_tool("gitleaks", …)`. If the user declines,
|
||||
or no pinned build exists for their platform, fall back to step 2b and say in your
|
||||
report that the sweep was manual.
|
||||
2. Scan working tree AND history — history matters most: a secret deleted in HEAD is still
|
||||
live in every clone, and it is the hit users are most surprised by.
|
||||
a. With gitleaks:
|
||||
`gitleaks detect --source . --report-format json --report-path /tmp/gitleaks.json`
|
||||
b. Without it, do the same job by hand, and say so:
|
||||
- working tree: `git grep -nIE '(api[_-]?key|secret|token|password|BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}|sk_(live|test)_[0-9a-zA-Z]{16,}|xox[baprs]-)'`
|
||||
- history, including files deleted since: `git log -p --all -S 'AKIA' --pickaxe-all`
|
||||
and `git log --diff-filter=D --name-only --pretty=format:%h -- '*.env*' '*credential*' '*secret*'`,
|
||||
then read the removed contents with `git show <sha>^:<path>`.
|
||||
- Pipe anything you read through a redactor rather than into your transcript, e.g.
|
||||
`sed -E "s/[A-Za-z0-9_\\-]{16,}/[REDACTED]/g"` — the no-printing rule still applies.
|
||||
3. Triage each hit by reading its context:
|
||||
- Real credential, test fixture, or example placeholder? Say which and why.
|
||||
- For real ones: what does it grant access to, and is it plausibly still valid?
|
||||
|
||||
@@ -4,9 +4,14 @@ description: Run a semgrep scan and turn findings into triaged, contextual fixes
|
||||
---
|
||||
Run a static-analysis pass with semgrep and own the findings end to end.
|
||||
|
||||
1. Check the tool: `semgrep --version`. If missing, tell the user how to install it
|
||||
(`pip install semgrep` or `brew install semgrep`) and STOP — ask before installing
|
||||
anything yourself.
|
||||
1. Check the tool: `semgrep --version`. If it's missing, ask for it with
|
||||
`request_tool("semgrep", …)` rather than skipping the pass. If the user declines,
|
||||
continue with a targeted manual review — read the routes/handlers, the auth and
|
||||
session code, every query built by string concatenation, deserialization, and
|
||||
outbound requests built from user input — and say in your report that the static
|
||||
pass was manual, so the user knows the coverage is narrower than a full scan.
|
||||
Note that community semgrep rules miss whole classes (e.g. SQL built through a
|
||||
project's own DB wrapper), so reading the code is worth doing even when it runs.
|
||||
2. Scan the repo (from its root):
|
||||
`semgrep scan --config auto --json --quiet -o /tmp/semgrep.json`
|
||||
Use `--config auto` unless the repo carries its own rules (`.semgrep.yml`,
|
||||
|
||||
@@ -161,6 +161,7 @@ from ..engine import ApprovalOutcome
|
||||
from ..inbox import VIS_INBOX, VIS_INLINE, args_preview
|
||||
from ..permissions import Mode
|
||||
from ..providers import AssistantTurn
|
||||
from .. import toolchain
|
||||
from .manager import SessionManager
|
||||
|
||||
|
||||
@@ -1694,6 +1695,52 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
)
|
||||
return answer_result(item.questions, await manager.inbox.wait(item.id))
|
||||
|
||||
async def tool_requester(args: dict, tool_call_id=None) -> dict:
|
||||
"""Park a TOOL_REQUESTED prompt, then install the PINNED build if approved.
|
||||
|
||||
Declining is a first-class outcome: the agent is told to fall back and disclose
|
||||
the gap rather than drop the check (OPE-85). Installs only ever come from the
|
||||
pinned registry with its digest verified — an approval is consent to install
|
||||
THAT artifact, not licence to fetch whatever a prompt asked for.
|
||||
"""
|
||||
name = str(args.get("name", "")).strip()
|
||||
info = toolchain.describe(name)
|
||||
item = manager.inbox.add_tool_request(
|
||||
session_id,
|
||||
f"Install {name}?" if name else "Install a tool?",
|
||||
body=str(args.get("reason", "")),
|
||||
inbox=_route(),
|
||||
visibility=_visibility(),
|
||||
data={
|
||||
"tool": name,
|
||||
"installable": bool(info),
|
||||
"version": (info or {}).get("version", ""),
|
||||
"summary": (info or {}).get("summary", ""),
|
||||
"url": (info or {}).get("url", ""),
|
||||
},
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
if item.state == "pending":
|
||||
manager.persist_session(session_id)
|
||||
if item.visibility == VIS_INBOX:
|
||||
await _mirror(item)
|
||||
resp = _parse_json(await manager.inbox.wait(item.id)) # {approved}
|
||||
if not resp.get("approved"):
|
||||
return {
|
||||
"installed": False,
|
||||
"reason": "the user declined to install it",
|
||||
}
|
||||
if not info:
|
||||
return {
|
||||
"installed": False,
|
||||
"error": f"no pinned build of {name} is available for this platform",
|
||||
}
|
||||
try:
|
||||
path = await asyncio.to_thread(toolchain.install, name)
|
||||
except Exception as exc: # noqa: BLE001 - surfaced to the agent verbatim
|
||||
return {"installed": False, "error": str(exc)}
|
||||
return {"installed": True, "path": path, "version": info["version"]}
|
||||
|
||||
async def directory_requester(args: dict, tool_call_id=None) -> dict:
|
||||
# The engine has already emitted DIRECTORY_REQUESTED. Park, await, then apply the grant.
|
||||
item = manager.inbox.add_directory(
|
||||
@@ -1805,6 +1852,7 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
directory_requester=directory_requester,
|
||||
plan_approver=plan_approver,
|
||||
question_asker=question_asker,
|
||||
tool_requester=tool_requester,
|
||||
)
|
||||
if engine is None:
|
||||
await ws.send_json(
|
||||
@@ -1941,6 +1989,10 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
}
|
||||
)
|
||||
)
|
||||
elif kind == "tool_response":
|
||||
_resolve_pending(
|
||||
json.dumps({"approved": bool(message.get("approved"))})
|
||||
)
|
||||
elif kind == "plan_response":
|
||||
_resolve_pending(
|
||||
json.dumps(
|
||||
|
||||
@@ -466,6 +466,7 @@ class SessionManager:
|
||||
directory_requester: Optional[Any] = None,
|
||||
plan_approver: Optional[Any] = None,
|
||||
question_asker: Optional[Any] = None,
|
||||
tool_requester: Optional[Any] = None,
|
||||
) -> Optional[TurnEngine]:
|
||||
engine = self._engines.get(session_id)
|
||||
if engine is not None:
|
||||
@@ -477,6 +478,8 @@ class SessionManager:
|
||||
engine.plan_approver = plan_approver
|
||||
if question_asker is not None:
|
||||
engine.question_asker = question_asker
|
||||
if tool_requester is not None:
|
||||
engine.tool_requester = tool_requester
|
||||
return engine
|
||||
|
||||
record = self.session_store.load(session_id)
|
||||
@@ -548,6 +551,7 @@ class SessionManager:
|
||||
plan_approver=plan_approver or self.inbox_plan_approver(session_id, agent),
|
||||
question_asker=question_asker
|
||||
or self.inbox_question_asker(session_id, agent),
|
||||
tool_requester=tool_requester,
|
||||
subscription_store=self.subscriptions,
|
||||
channel_buffer=self.channel_buffer,
|
||||
routing_targets=self._routing_targets(session_id, agent),
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Finding (and optionally installing) the CLI tools a coworker's skills drive.
|
||||
|
||||
Two problems, deliberately kept apart (OPE-82):
|
||||
|
||||
* **The user's own toolchain** — aws, kubectl, terraform, gh, node. The whole point is
|
||||
*their* installed, configured, credentialed copy, so we only ever LOCATE these. The
|
||||
desktop shell hands us the login shell's PATH at spawn (OPE-83); `resolve()` is the
|
||||
belt-and-braces for every other launch path (headless, systemd, a double-clicked
|
||||
binary) — it also searches the dirs launchd's PATH never covers.
|
||||
* **Tools a skill fundamentally IS** — the scanners behind the security bundles. Those
|
||||
we can install and PIN, so a security review is reproducible instead of depending on
|
||||
whatever version the user's package manager happened to ship.
|
||||
|
||||
Everything returns an ABSOLUTE path: once resolved, invocation never depends on PATH
|
||||
again, so a tool found here works even if the caller's environment is bare.
|
||||
|
||||
Nothing here downloads anything on its own. `install()` runs only when the user has
|
||||
approved it (via `request_tool`, OPE-85) — fetching an executable is a supply-chain
|
||||
decision, so it is pinned by version, verified by SHA-256, and never implicit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from .secrets import state_dir
|
||||
|
||||
# Dirs that hold user-installed CLIs but never appear in launchd's PATH. Mirrors
|
||||
# KNOWN_TOOL_DIRS in the desktop shell (src-tauri/src/lib.rs) — keep the two in step.
|
||||
_KNOWN_DIRS: tuple[str, ...] = (
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
"/usr/local/bin",
|
||||
"/usr/local/sbin",
|
||||
"/opt/local/bin",
|
||||
"~/.local/bin",
|
||||
"~/.cargo/bin",
|
||||
"~/go/bin",
|
||||
)
|
||||
|
||||
|
||||
def managed_dir() -> Path:
|
||||
"""Where we keep tools we installed ourselves (never the user's own copies)."""
|
||||
return state_dir() / "tools"
|
||||
|
||||
|
||||
def _platform_key() -> str:
|
||||
"""`<os>_<arch>` using the naming the upstream release assets use."""
|
||||
system = {"darwin": "darwin", "linux": "linux", "win32": "windows"}.get(
|
||||
sys.platform, sys.platform
|
||||
)
|
||||
machine = platform.machine().lower()
|
||||
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
|
||||
return f"{system}_{arch}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Download:
|
||||
url: str
|
||||
sha256: str
|
||||
# Path of the binary inside the archive; None when the asset IS the binary.
|
||||
member: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagedTool:
|
||||
name: str
|
||||
version: str
|
||||
# platform key -> download
|
||||
downloads: dict[str, Download]
|
||||
summary: str
|
||||
|
||||
|
||||
# Pinned scanner registry. Versions and digests are copied from the upstream release's
|
||||
# own checksum manifest; bumping a tool means bumping the digest in the same commit.
|
||||
#
|
||||
# Not every scanner belongs here: semgrep is distributed as a Python package (pip/brew),
|
||||
# and trivy/tfsec ship per-distro packaging — for those we resolve the user's install
|
||||
# rather than half-managing a copy. That's a deliberate line, not an omission.
|
||||
MANAGED: dict[str, ManagedTool] = {
|
||||
"gitleaks": ManagedTool(
|
||||
name="gitleaks",
|
||||
version="8.30.1",
|
||||
summary="scans git history and the working tree for committed secrets",
|
||||
downloads={
|
||||
"darwin_arm64": Download(
|
||||
url="https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_darwin_arm64.tar.gz",
|
||||
sha256="b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5",
|
||||
member="gitleaks",
|
||||
),
|
||||
"darwin_amd64": Download(
|
||||
url="https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_darwin_x64.tar.gz",
|
||||
sha256="dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709",
|
||||
member="gitleaks",
|
||||
),
|
||||
"linux_amd64": Download(
|
||||
url="https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz",
|
||||
sha256="551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb",
|
||||
member="gitleaks",
|
||||
),
|
||||
},
|
||||
),
|
||||
"osv-scanner": ManagedTool(
|
||||
name="osv-scanner",
|
||||
version="2.5.0",
|
||||
summary="checks dependency lockfiles against the OSV vulnerability database",
|
||||
downloads={
|
||||
"darwin_arm64": Download(
|
||||
url="https://github.com/google/osv-scanner/releases/download/v2.5.0/osv-scanner_darwin_arm64",
|
||||
sha256="fff5a2e351b7f0a60001e87cbf862e82fb82e2792d368b533fec7a5865a73da2",
|
||||
),
|
||||
"darwin_amd64": Download(
|
||||
url="https://github.com/google/osv-scanner/releases/download/v2.5.0/osv-scanner_darwin_amd64",
|
||||
sha256="baef4f4a4ce2924a9241869c36d4bd9d6c04b632cae6637a0f6347ab9272eb16",
|
||||
),
|
||||
"linux_amd64": Download(
|
||||
url="https://github.com/google/osv-scanner/releases/download/v2.5.0/osv-scanner_linux_amd64",
|
||||
sha256="edcfc41d257db36148f065055655fe3fcfc434b0b423ea67468a84c207524e0c",
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _managed_path(tool: ManagedTool) -> Path:
|
||||
exe = tool.name + (".exe" if sys.platform == "win32" else "")
|
||||
return managed_dir() / tool.name / tool.version / exe
|
||||
|
||||
|
||||
def resolve(name: str) -> Optional[str]:
|
||||
"""Absolute path to `name`, or None. PATH first (the user's choice wins), then the
|
||||
dirs a GUI launch can't see, then anything we installed ourselves."""
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return str(Path(found).resolve())
|
||||
|
||||
for raw in _KNOWN_DIRS:
|
||||
candidate = Path(raw).expanduser() / name
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate.resolve())
|
||||
|
||||
tool = MANAGED.get(name)
|
||||
if tool:
|
||||
managed = _managed_path(tool)
|
||||
if managed.is_file() and os.access(managed, os.X_OK):
|
||||
return str(managed)
|
||||
return None
|
||||
|
||||
|
||||
def have(name: str) -> bool:
|
||||
return resolve(name) is not None
|
||||
|
||||
|
||||
def missing(names: Iterable[str]) -> list[str]:
|
||||
"""Which of `names` we can't find — what a skill checks before promising a scan."""
|
||||
return [n for n in names if not have(n)]
|
||||
|
||||
|
||||
def installable(name: str) -> bool:
|
||||
"""Whether we could install this ourselves (i.e. it's pinned for this platform)."""
|
||||
tool = MANAGED.get(name)
|
||||
return bool(tool and _platform_key() in tool.downloads)
|
||||
|
||||
|
||||
def describe(name: str) -> Optional[dict[str, str]]:
|
||||
"""What to show the user when asking permission to install (OPE-85)."""
|
||||
tool = MANAGED.get(name)
|
||||
if not tool:
|
||||
return None
|
||||
dl = tool.downloads.get(_platform_key())
|
||||
if not dl:
|
||||
return None
|
||||
return {
|
||||
"name": tool.name,
|
||||
"version": tool.version,
|
||||
"summary": tool.summary,
|
||||
"url": dl.url,
|
||||
"sha256": dl.sha256,
|
||||
}
|
||||
|
||||
|
||||
def _verify(blob: bytes, expected: str) -> None:
|
||||
actual = hashlib.sha256(blob).hexdigest()
|
||||
if actual != expected:
|
||||
raise ValueError(
|
||||
f"checksum mismatch: expected {expected}, got {actual} — refusing to install"
|
||||
)
|
||||
|
||||
|
||||
def install(name: str, *, timeout: int = 120) -> str:
|
||||
"""Install a pinned tool and return its absolute path.
|
||||
|
||||
Only ever called after the user approves the request. The download is verified
|
||||
against the pinned digest BEFORE anything is written to its final location, so a
|
||||
tampered or truncated artifact never becomes an executable on disk.
|
||||
"""
|
||||
tool = MANAGED.get(name)
|
||||
if not tool:
|
||||
raise KeyError(f"{name} is not a managed tool")
|
||||
dl = tool.downloads.get(_platform_key())
|
||||
if not dl:
|
||||
raise KeyError(f"{name} has no pinned build for {_platform_key()}")
|
||||
|
||||
target = _managed_path(tool)
|
||||
if target.is_file() and os.access(target, os.X_OK):
|
||||
return str(target)
|
||||
|
||||
with urllib.request.urlopen(dl.url, timeout=timeout) as resp: # noqa: S310 - pinned URL
|
||||
blob = resp.read()
|
||||
_verify(blob, dl.sha256)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
if dl.member:
|
||||
archive = tmp_path / "asset.tar.gz"
|
||||
archive.write_bytes(blob)
|
||||
with tarfile.open(archive) as tf:
|
||||
extracted = tf.extractfile(dl.member)
|
||||
if extracted is None:
|
||||
raise ValueError(f"{dl.member} missing from {name} archive")
|
||||
payload = extracted.read()
|
||||
else:
|
||||
payload = blob
|
||||
|
||||
staged = tmp_path / "binary"
|
||||
staged.write_bytes(payload)
|
||||
staged.chmod(staged.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
shutil.move(str(staged), str(target))
|
||||
|
||||
return str(target)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""The `request_tool` tool — the agent asks the user for a CLI it needs but can't find.
|
||||
|
||||
Sibling of `request_directory`: the TurnEngine intercepts it, emits TOOL_REQUESTED, and the
|
||||
user decides out-of-band (install the pinned build, or skip and let the run continue
|
||||
degraded). The callable here is only a schema carrier + the fallback for surfaces with no
|
||||
requester wired.
|
||||
|
||||
This exists because of a specific failure mode (OPE-85): with gitleaks absent, a security
|
||||
review silently dropped its git-history secret scan — the check didn't fail, it vanished
|
||||
from the report. A missing tool must become a visible decision, never an invisible gap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aisuite.agents import ToolMetadata, tool
|
||||
|
||||
|
||||
def request_tool_tool() -> object:
|
||||
def request_tool(name: str, reason: str) -> dict:
|
||||
"""Ask the user to install a command-line tool you need but can't find on this
|
||||
machine (e.g. `gitleaks`, `osv-scanner`, `semgrep`). Say in `reason` what check it
|
||||
unlocks, so the user can judge whether it's worth installing.
|
||||
|
||||
Use this INSTEAD of quietly skipping a check. If the user declines, carry on with a
|
||||
fallback (e.g. reading git history yourself instead of running gitleaks) and state
|
||||
plainly in your report which checks were degraded and why.
|
||||
"""
|
||||
return {
|
||||
"installed": False,
|
||||
"error": "tool requests aren't available in this surface",
|
||||
}
|
||||
|
||||
return tool(
|
||||
request_tool,
|
||||
metadata=ToolMetadata(
|
||||
category="system",
|
||||
risk_level="low",
|
||||
capabilities=["request_tool"],
|
||||
description=(
|
||||
"Ask the user to install a missing command-line tool, rather than silently "
|
||||
"skipping the check that needs it."
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -610,6 +610,17 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
});
|
||||
return; // suspended on the approval
|
||||
}
|
||||
// OPE-85: the agent hits a missing scanner and asks instead of skipping the check.
|
||||
if (/scan for secrets/i.test(msg.text)) {
|
||||
send("tool_requested", {
|
||||
name: "gitleaks",
|
||||
reason: "scan the git history for committed secrets",
|
||||
installable: true,
|
||||
version: "8.30.1",
|
||||
summary: "scans git history and the working tree for committed secrets",
|
||||
});
|
||||
return; // suspended on the tool request
|
||||
}
|
||||
// §35 compact row: a routine workspace write (content rides in the args).
|
||||
if (/write a file/i.test(msg.text)) {
|
||||
pendingTool = "write_file";
|
||||
@@ -767,6 +778,19 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` });
|
||||
}
|
||||
send("turn_done");
|
||||
} else if (msg.type === "tool_response") {
|
||||
// Either way the turn continues — the point of the contract is that declining
|
||||
// degrades the report openly instead of dropping the check.
|
||||
if (msg.approved) {
|
||||
send("assistant_message", {
|
||||
text: "Installed gitleaks 8.30.1 — scanned history, no secrets found.",
|
||||
});
|
||||
} else {
|
||||
send("assistant_message", {
|
||||
text: "Skipped gitleaks. Coverage: history secret sweep done by hand instead.",
|
||||
});
|
||||
}
|
||||
send("turn_done");
|
||||
} else if (msg.type === "interrupt") {
|
||||
// Stop mid-stream: like the real engine, end the turn with `interrupted` and
|
||||
// NO assistant_message — the client owns promoting the partial into the transcript.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// OPE-85: a missing CLI becomes a visible decision, never a silently dropped check.
|
||||
// The bug this guards (owner-hit 2026-08-13): with gitleaks absent, a security review
|
||||
// quietly omitted its git-history secret scan — "we couldn't look" rendered as "clean".
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
|
||||
async function ask(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByPlaceholder(/Ask the coworker/).fill("scan for secrets");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
}
|
||||
|
||||
test("request_tool surfaces a card naming the tool, the reason and the pinned version", async ({
|
||||
page,
|
||||
}) => {
|
||||
await ask(page);
|
||||
const card = page.locator(".dirreq-card");
|
||||
await expect(card).toContainText("gitleaks");
|
||||
await expect(card).toContainText("scan the git history for committed secrets");
|
||||
await expect(card).toContainText("8.30.1");
|
||||
await expect(card).toContainText(/checksum-verified/i);
|
||||
// Declining must read as a normal choice, not a failure.
|
||||
await expect(card.getByTestId("toolreq-skip")).toBeVisible();
|
||||
});
|
||||
|
||||
test("installing runs the check; skipping still reports coverage", async ({ page }) => {
|
||||
await ask(page);
|
||||
await page.getByTestId("toolreq-install").click();
|
||||
await expect(page.locator(".main-scroll")).toContainText("Installed gitleaks");
|
||||
|
||||
await page.getByPlaceholder(/Ask the coworker/).fill("scan for secrets");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
await page.getByTestId("toolreq-skip").click();
|
||||
// The whole point: the skipped check is disclosed, not invisible.
|
||||
await expect(page.locator(".main-scroll")).toContainText(/Coverage:/);
|
||||
});
|
||||
@@ -47,6 +47,126 @@ fn launch_token() -> String {
|
||||
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
/// Directories where user-installed CLIs live but launchd's PATH never looks. Used to
|
||||
/// repair PATH when the login-shell probe can't run (broken profile, exotic shell).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const KNOWN_TOOL_DIRS: &[&str] = &[
|
||||
"/opt/homebrew/bin", // Apple Silicon Homebrew
|
||||
"/opt/homebrew/sbin",
|
||||
"/usr/local/bin", // Intel Homebrew, most installers
|
||||
"/usr/local/sbin",
|
||||
"/opt/local/bin", // MacPorts
|
||||
];
|
||||
|
||||
/// The environment the sidecar should run with (OPE-83).
|
||||
///
|
||||
/// A Finder/Dock-launched app inherits launchd's minimal PATH — `/usr/bin:/bin:/usr/sbin:/sbin`
|
||||
/// — so every tool the user installed via Homebrew/nvm/pyenv/asdf is invisible to the agent:
|
||||
/// semgrep, gitleaks, gh, node, aws, kubectl, terraform. That silently guts the security
|
||||
/// coworkers (they drive those scanners) and every ops workflow. Fix, same as VS Code and
|
||||
/// friends: ask the user's login shell for its environment once at spawn and merge it in, so
|
||||
/// the coworker gets the user's REAL toolchain. Credentials follow for free — aws/kubectl read
|
||||
/// ~/.aws and ~/.kube via HOME, which a Finder launch already has.
|
||||
///
|
||||
/// Guards: `-i` (not just `-l`) because brew/nvm/pyenv init usually lives in .zshrc; markers so
|
||||
/// a chatty profile's own output can't be parsed as variables; a 5s timeout with the child
|
||||
/// killed, so a hanging profile can never block app launch; and a well-known-dirs PATH repair as
|
||||
/// the fallback. Skipped entirely when we were launched FROM a shell (SHLVL set) — we already
|
||||
/// inherit the real thing, and `npm run tauri dev` should behave exactly as before.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn sidecar_env() -> std::collections::HashMap<String, String> {
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
const START: &str = "__OCW_ENV_START__";
|
||||
const END: &str = "__OCW_ENV_END__";
|
||||
|
||||
let mut out: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Launched from a shell (dev run, `open` from a terminal): the env is already real.
|
||||
if std::env::var_os("SHLVL").is_some() {
|
||||
return out;
|
||||
}
|
||||
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
let script = format!("echo {START}; env; echo {END}");
|
||||
let spawned = Command::new(&shell)
|
||||
.args(["-ilc", &script])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
|
||||
if let Ok(mut child) = spawned {
|
||||
if let Some(mut stdout) = child.stdout.take() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
let _ = stdout.read_to_string(&mut buf);
|
||||
let _ = tx.send(buf);
|
||||
});
|
||||
match rx.recv_timeout(Duration::from_secs(5)) {
|
||||
Ok(text) => {
|
||||
let _ = child.wait();
|
||||
let mut inside = false;
|
||||
for line in text.lines() {
|
||||
if line.trim_end() == START {
|
||||
inside = true;
|
||||
continue;
|
||||
}
|
||||
if line.trim_end() == END {
|
||||
break;
|
||||
}
|
||||
if !inside {
|
||||
continue;
|
||||
}
|
||||
// `env` prints KEY=value; continuation lines of a multi-line value
|
||||
// have no '=' before whitespace and are skipped rather than guessed at.
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
if !k.is_empty() && !k.contains(char::is_whitespace) {
|
||||
out.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Hung profile — never let it hold up launch.
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// These describe the probe shell, not the user's environment.
|
||||
for k in ["SHLVL", "PWD", "OLDPWD", "_"] {
|
||||
out.remove(k);
|
||||
}
|
||||
|
||||
// Whether the probe worked or not, make sure the usual install dirs are reachable.
|
||||
let base = out
|
||||
.get("PATH")
|
||||
.cloned()
|
||||
.or_else(|| std::env::var("PATH").ok())
|
||||
.unwrap_or_default();
|
||||
let mut parts: Vec<String> = base.split(':').filter(|s| !s.is_empty()).map(String::from).collect();
|
||||
for dir in KNOWN_TOOL_DIRS {
|
||||
if !parts.iter().any(|p| p == dir) && std::path::Path::new(dir).is_dir() {
|
||||
parts.push((*dir).to_string());
|
||||
}
|
||||
}
|
||||
out.insert("PATH".to_string(), parts.join(":"));
|
||||
out
|
||||
}
|
||||
|
||||
/// Windows GUI apps inherit the user's full environment already.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn sidecar_env() -> std::collections::HashMap<String, String> {
|
||||
std::collections::HashMap::new()
|
||||
}
|
||||
|
||||
/// Path to the server entrypoint. Resolution order:
|
||||
/// 1. `COWORKER_SERVER_BIN` env override.
|
||||
/// 2. The bundled onedir sidecar shipped via Tauri `resources` (production): the
|
||||
@@ -629,6 +749,10 @@ pub fn run() {
|
||||
let mut server_cmd = Command::new(server_bin());
|
||||
server_cmd
|
||||
.args(["--host", "127.0.0.1", "--port", &port.to_string()])
|
||||
// The user's real shell environment (PATH to their tools, AWS_PROFILE,
|
||||
// KUBECONFIG, …) — see sidecar_env(). Applied FIRST so the explicit COWORKER_*
|
||||
// vars below always win over anything a profile happens to export.
|
||||
.envs(sidecar_env())
|
||||
// The sidecar self-exits if we die abruptly (dev-watcher restart, crash) —
|
||||
// belt-and-suspenders alongside the RunEvent::ExitRequested kill below.
|
||||
// The explicit PID matters: under PyInstaller onefile the python process is a
|
||||
|
||||
@@ -69,6 +69,7 @@ import { PersonaView } from "./components/PersonaView";
|
||||
import { AuditView } from "./components/AuditView";
|
||||
import { InboxView } from "./components/InboxView";
|
||||
import { ApprovalCard } from "./components/ApprovalCard";
|
||||
import { ToolRequestCard } from "./components/ToolRequestCard";
|
||||
import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
|
||||
import { PlanCard } from "./components/PlanCard";
|
||||
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
|
||||
@@ -728,6 +729,20 @@ export function App() {
|
||||
{ kind: "dirreq", reason: d.reason || "", path: d.path || "", writable: !!d.writable },
|
||||
]);
|
||||
break;
|
||||
case "tool_requested":
|
||||
if (unattendedRef.current) break;
|
||||
setItems((p) => [
|
||||
...p,
|
||||
{
|
||||
kind: "toolreq",
|
||||
tool: d.name || "",
|
||||
reason: d.reason || "",
|
||||
installable: d.installable !== false,
|
||||
version: d.version || "",
|
||||
summary: d.summary || "",
|
||||
},
|
||||
]);
|
||||
break;
|
||||
case "plan_proposed":
|
||||
if (unattendedRef.current) break;
|
||||
setItems((p) => [...p, { kind: "planreq", plan: d.plan || "" }]);
|
||||
@@ -980,6 +995,11 @@ export function App() {
|
||||
dropSessionInbox("directory");
|
||||
sessionRef.current?.respondDirectory(granted, path, writable);
|
||||
};
|
||||
const respondTool = (approved: boolean) => {
|
||||
setItems((p) => resolveLastToolReq(p, approved ? "installed" : "skipped"));
|
||||
dropSessionInbox("tool");
|
||||
sessionRef.current?.respondTool(approved);
|
||||
};
|
||||
const answerQuestion = (answer: string) => {
|
||||
setItems((p) => resolveLastQuestion(p, answer));
|
||||
dropSessionInbox("question");
|
||||
@@ -1341,6 +1361,7 @@ export function App() {
|
||||
const idle = items.length === 0 && !streaming;
|
||||
const pendingApproval = [...items].reverse().find((i) => i.kind === "approval" && !i.resolved);
|
||||
const pendingDirReq = [...items].reverse().find((i) => i.kind === "dirreq" && !i.resolved);
|
||||
const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved);
|
||||
const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved);
|
||||
const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved);
|
||||
// Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the
|
||||
@@ -1857,6 +1878,8 @@ export function App() {
|
||||
// parked in the Inbox and surfaced via the answer-in-context card below.
|
||||
!unattended && pendingPlan?.kind === "planreq" ? (
|
||||
<PlanCard item={pendingPlan} onRespond={respondPlan} />
|
||||
) : !unattended && pendingToolReq?.kind === "toolreq" ? (
|
||||
<ToolRequestCard item={pendingToolReq} onRespond={respondTool} />
|
||||
) : !unattended && pendingDirReq?.kind === "dirreq" ? (
|
||||
<DirectoryRequestCard item={pendingDirReq} onRespond={respondDirectory} />
|
||||
) : !unattended && pendingApproval?.kind === "approval" ? (
|
||||
@@ -2029,6 +2052,18 @@ function resolveLastDirReq(items: Item[], resolved: "granted" | "denied"): Item[
|
||||
return copy;
|
||||
}
|
||||
|
||||
function resolveLastToolReq(items: Item[], resolved: "installed" | "skipped"): Item[] {
|
||||
const copy = [...items];
|
||||
for (let i = copy.length - 1; i >= 0; i--) {
|
||||
const it = copy[i];
|
||||
if (it.kind === "toolreq" && !it.resolved) {
|
||||
copy[i] = { ...it, resolved };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function resolveLastPlan(items: Item[], resolved: "approved" | "rejected"): Item[] {
|
||||
const copy = [...items];
|
||||
for (let i = copy.length - 1; i >= 0; i--) {
|
||||
|
||||
@@ -2124,6 +2124,11 @@ export class Session {
|
||||
this.send({ type: "directory_response", granted, ...(path ? { path } : {}), writable: !!writable });
|
||||
}
|
||||
|
||||
// Reply to a `request_tool` prompt: install the pinned build, or skip the check.
|
||||
respondTool(approved: boolean) {
|
||||
this.send({ type: "tool_response", approved });
|
||||
}
|
||||
|
||||
// Reply to a `propose_plan` prompt: approve (choosing the execution mode) or reject with feedback.
|
||||
respondPlan(approved: boolean, mode?: string, feedback?: string) {
|
||||
this.send({
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Item } from "../types";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
type ToolReqItem = Extract<Item, { kind: "toolreq" }>;
|
||||
|
||||
// The agent asked (via request_tool) for a CLI it couldn't find — a scanner, usually.
|
||||
// Declining is a normal outcome, not a failure: the agent falls back and says which checks
|
||||
// were degraded, so the copy here shouldn't push the user toward Install.
|
||||
export function ToolRequestCard({
|
||||
item,
|
||||
onRespond,
|
||||
}: {
|
||||
item: ToolReqItem;
|
||||
onRespond: (approved: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="dirreq-card">
|
||||
<div className="dirreq-head">
|
||||
<Icon name="wrench" size={16} className="ico" />
|
||||
<span>
|
||||
The coworker needs <code>{item.tool}</code>
|
||||
</span>
|
||||
</div>
|
||||
{item.reason && <div className="dirreq-reason">“{item.reason}”</div>}
|
||||
{item.installable ? (
|
||||
<div className="dirreq-reason">
|
||||
{item.summary ? `${item.summary}. ` : ""}
|
||||
Installs {item.tool}
|
||||
{item.version ? ` ${item.version}` : ""} — a pinned build, checksum-verified before
|
||||
it runs.
|
||||
</div>
|
||||
) : (
|
||||
<div className="dirreq-reason">
|
||||
No verified build is available for this machine — install it yourself if you want
|
||||
this check, or skip and the coworker will note the gap.
|
||||
</div>
|
||||
)}
|
||||
<div className="dirreq-actions">
|
||||
<span className="spacer" />
|
||||
<button className="btn" data-testid="toolreq-skip" onClick={() => onRespond(false)}>
|
||||
Skip this check
|
||||
</button>
|
||||
<button
|
||||
className="btn primary"
|
||||
data-testid="toolreq-install"
|
||||
disabled={!item.installable}
|
||||
onClick={() => onRespond(true)}
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type EventType =
|
||||
| "tool_proposed"
|
||||
| "permission_required"
|
||||
| "directory_requested"
|
||||
| "tool_requested"
|
||||
| "question_requested"
|
||||
| "plan_proposed"
|
||||
| "tool_started"
|
||||
@@ -128,6 +129,15 @@ export type Item =
|
||||
writable?: boolean;
|
||||
resolved?: "granted" | "denied";
|
||||
}
|
||||
| {
|
||||
kind: "toolreq";
|
||||
tool: string;
|
||||
reason: string;
|
||||
installable?: boolean;
|
||||
version?: string;
|
||||
summary?: string;
|
||||
resolved?: "installed" | "skipped";
|
||||
}
|
||||
| {
|
||||
kind: "planreq";
|
||||
plan: string;
|
||||
|
||||
@@ -80,3 +80,35 @@ def test_prompts_carry_the_positioning_guardrails(tmp_path):
|
||||
assert "drive" in prompt # drives scanners; value is judgment/remediation
|
||||
assert "read-only" in reg.get("cloud-posture").manifest.system_prompt.lower()
|
||||
assert "never print a discovered secret" in reg.get("security").manifest.system_prompt.lower()
|
||||
|
||||
|
||||
def test_security_prompt_forbids_silently_skipping_a_check(tmp_path):
|
||||
"""OPE-85, owner-hit 2026-08-13: with gitleaks unavailable the review silently dropped
|
||||
its git-history secret scan — the check didn't fail, it vanished. For a security tool,
|
||||
"no tool" rendering as "clean" is the worst possible outcome, so the contract lives in
|
||||
the prompt and is pinned here."""
|
||||
reg = _reg(tmp_path)
|
||||
prompt = reg.get("security").manifest.system_prompt.lower()
|
||||
assert "never silently skip" in prompt
|
||||
assert "coverage" in prompt # every review reports what ran and what didn't
|
||||
assert "request_tool" in prompt # asking is the first option, not skipping
|
||||
|
||||
|
||||
def test_scanner_skills_offer_a_fallback_instead_of_stopping(tmp_path):
|
||||
"""The skills used to say "if missing … and STOP", which is precisely the instruction
|
||||
that produced the vanished check. A missing tool must lead to request_tool or a manual
|
||||
equivalent — never to a dropped step."""
|
||||
import coworker.personas as personas_pkg
|
||||
|
||||
root = Path(personas_pkg.__file__).parent / "builtin" / "security" / "skills"
|
||||
secret_scan = (root / "secret-scan" / "SKILL.md").read_text()
|
||||
semgrep = (root / "semgrep-review" / "SKILL.md").read_text()
|
||||
|
||||
for body in (secret_scan, semgrep):
|
||||
assert "request_tool" in body
|
||||
assert "STOP" not in body
|
||||
|
||||
# The history sweep is the check that actually went missing — it must survive without
|
||||
# gitleaks, and the no-printing rule must survive the manual path too.
|
||||
assert "git log -p" in secret_scan
|
||||
assert "REDACTED" in secret_scan
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""`request_tool` — the agent asks for a missing CLI instead of dropping the check (OPE-85).
|
||||
|
||||
Engine-intercepted like `request_directory`: it never goes through the permission path,
|
||||
because the user's out-of-band decision IS the consent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from coworker.engine import EventType, TurnEngine
|
||||
from coworker.permissions import Mode, PermissionEngine
|
||||
from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient, ToolCall
|
||||
from coworker.tools import ToolRegistry
|
||||
|
||||
|
||||
class ScriptedProvider(ProviderClient):
|
||||
"""One turn that calls request_tool, then a plain reply."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return AssistantTurn(
|
||||
text="",
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="t1",
|
||||
name="request_tool",
|
||||
arguments={"name": "gitleaks", "reason": "scan history for secrets"},
|
||||
)
|
||||
],
|
||||
)
|
||||
return AssistantTurn(text="done", tool_calls=[])
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities(tools=True)
|
||||
|
||||
|
||||
def _engine(tmp_path, requester):
|
||||
return TurnEngine(
|
||||
provider=ScriptedProvider(),
|
||||
registry=ToolRegistry(),
|
||||
permissions=PermissionEngine(workspace_root=tmp_path, mode=Mode.INTERACTIVE),
|
||||
model="m",
|
||||
tool_requester=requester,
|
||||
)
|
||||
|
||||
|
||||
async def _run(engine) -> list:
|
||||
return [e async for e in engine.run("check this repo")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_tool_requested_and_reports_install(tmp_path):
|
||||
async def requester(args, tool_call_id=None):
|
||||
assert args["name"] == "gitleaks"
|
||||
return {"installed": True, "path": "/tmp/gitleaks", "version": "8.30.1"}
|
||||
|
||||
events = await _run(_engine(tmp_path, requester))
|
||||
requested = [e for e in events if e.type is EventType.TOOL_REQUESTED]
|
||||
assert requested and requested[0].data["name"] == "gitleaks"
|
||||
finished = [e for e in events if e.type is EventType.TOOL_FINISHED]
|
||||
assert finished[0].data["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_declining_tells_the_agent_to_fall_back_openly(tmp_path):
|
||||
"""A refusal must not read as 'check done'. The tool result has to push the agent
|
||||
toward a disclosed fallback, which is the whole point of the contract."""
|
||||
|
||||
async def requester(args, tool_call_id=None):
|
||||
return {"installed": False, "reason": "the user declined to install it"}
|
||||
|
||||
engine = _engine(tmp_path, requester)
|
||||
events = await _run(engine)
|
||||
assert [e for e in events if e.type is EventType.TOOL_REQUESTED]
|
||||
finished = [e for e in events if e.type is EventType.TOOL_FINISHED]
|
||||
assert finished[0].data["status"] == "denied"
|
||||
|
||||
tool_msg = [m for m in engine.messages if m.get("role") == "tool"][-1]
|
||||
body = str(tool_msg["content"]).lower()
|
||||
assert "degraded" in body or "fallback" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_requester_still_returns_guidance(tmp_path):
|
||||
"""Headless surfaces have nobody to ask — the agent must still be told to disclose
|
||||
rather than assume the check passed."""
|
||||
engine = _engine(tmp_path, None)
|
||||
events = await _run(engine)
|
||||
assert not [e for e in events if e.type is EventType.TOOL_REQUESTED]
|
||||
tool_msg = [m for m in engine.messages if m.get("role") == "tool"][-1]
|
||||
assert "degraded" in str(tool_msg["content"]).lower()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tool resolution + pinned managed installs (OPE-84).
|
||||
|
||||
The bug this guards against: a Finder-launched app gets launchd's minimal PATH, so every
|
||||
brew/nvm-installed scanner is invisible and a security review silently loses checks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from coworker import toolchain
|
||||
|
||||
|
||||
def _make_exe(path, body: str = "#!/bin/sh\necho hi\n"):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body)
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR)
|
||||
return path
|
||||
|
||||
|
||||
def test_resolve_prefers_path(tmp_path, monkeypatch):
|
||||
on_path = _make_exe(tmp_path / "bin" / "semgrep")
|
||||
monkeypatch.setenv("PATH", str(tmp_path / "bin"))
|
||||
assert toolchain.resolve("semgrep") == str(on_path.resolve())
|
||||
|
||||
|
||||
def test_resolve_finds_tools_launchd_path_cannot_see(tmp_path, monkeypatch):
|
||||
"""The actual production failure: PATH is bare, the tool is in a brew-style dir."""
|
||||
brew = tmp_path / "opt" / "homebrew" / "bin"
|
||||
gitleaks = _make_exe(brew / "gitleaks")
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin") # what a Finder launch really gets
|
||||
monkeypatch.setattr(toolchain, "_KNOWN_DIRS", (str(brew),))
|
||||
assert toolchain.resolve("gitleaks") == str(gitleaks.resolve())
|
||||
|
||||
|
||||
def test_resolve_returns_absolute_path(tmp_path, monkeypatch):
|
||||
"""Callers must be able to invoke without depending on PATH at all."""
|
||||
_make_exe(tmp_path / "bin" / "trivy")
|
||||
monkeypatch.setenv("PATH", str(tmp_path / "bin"))
|
||||
assert os.path.isabs(toolchain.resolve("trivy") or "")
|
||||
|
||||
|
||||
def test_missing_reports_only_absent_tools(tmp_path, monkeypatch):
|
||||
_make_exe(tmp_path / "bin" / "gitleaks")
|
||||
monkeypatch.setenv("PATH", str(tmp_path / "bin"))
|
||||
monkeypatch.setattr(toolchain, "_KNOWN_DIRS", ())
|
||||
monkeypatch.setattr(toolchain, "MANAGED", {})
|
||||
assert toolchain.missing(["gitleaks", "semgrep"]) == ["semgrep"]
|
||||
|
||||
|
||||
def test_unknown_tool_resolves_to_none(monkeypatch):
|
||||
monkeypatch.setenv("PATH", "")
|
||||
monkeypatch.setattr(toolchain, "_KNOWN_DIRS", ())
|
||||
assert toolchain.resolve("definitely-not-a-real-tool") is None
|
||||
|
||||
|
||||
def test_registry_entries_are_pinned_and_digested():
|
||||
"""Every managed download must carry a version and a full SHA-256 — an unpinned
|
||||
entry would mean 'download whatever is current', which is the thing we refuse."""
|
||||
assert toolchain.MANAGED, "registry should not be empty"
|
||||
for name, tool in toolchain.MANAGED.items():
|
||||
assert tool.version and tool.version[0].isdigit(), name
|
||||
assert tool.summary, f"{name} needs a summary for the consent card"
|
||||
for key, dl in tool.downloads.items():
|
||||
assert len(dl.sha256) == 64, f"{name}/{key} digest looks wrong"
|
||||
assert int(dl.sha256, 16) >= 0 # hex
|
||||
assert tool.version in dl.url, f"{name}/{key} url must pin the version"
|
||||
assert dl.url.startswith("https://"), f"{name}/{key} must be https"
|
||||
|
||||
|
||||
def test_describe_surfaces_what_the_user_is_approving(monkeypatch):
|
||||
monkeypatch.setattr(toolchain, "_platform_key", lambda: "darwin_arm64")
|
||||
info = toolchain.describe("gitleaks")
|
||||
assert info and info["version"] and info["sha256"] and info["url"]
|
||||
assert "secret" in info["summary"].lower()
|
||||
|
||||
|
||||
def test_install_refuses_a_tampered_download(tmp_path, monkeypatch):
|
||||
"""The whole point of pinning: a mismatched artifact never lands on disk."""
|
||||
monkeypatch.setattr(toolchain, "managed_dir", lambda: tmp_path / "tools")
|
||||
monkeypatch.setattr(toolchain, "_platform_key", lambda: "darwin_arm64")
|
||||
|
||||
class FakeResp:
|
||||
def read(self):
|
||||
return b"malicious payload"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(toolchain.urllib.request, "urlopen", lambda *a, **k: FakeResp())
|
||||
|
||||
with pytest.raises(ValueError, match="checksum mismatch"):
|
||||
toolchain.install("gitleaks")
|
||||
assert not (tmp_path / "tools").exists() or not list(
|
||||
(tmp_path / "tools").rglob("gitleaks")
|
||||
)
|
||||
|
||||
|
||||
def test_install_writes_a_verified_binary(tmp_path, monkeypatch):
|
||||
payload = b"#!/bin/sh\necho scanned\n"
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
|
||||
monkeypatch.setattr(toolchain, "managed_dir", lambda: tmp_path / "tools")
|
||||
monkeypatch.setattr(toolchain, "_platform_key", lambda: "darwin_arm64")
|
||||
monkeypatch.setattr(
|
||||
toolchain,
|
||||
"MANAGED",
|
||||
{
|
||||
"osv-scanner": toolchain.ManagedTool(
|
||||
name="osv-scanner",
|
||||
version="2.5.0",
|
||||
summary="checks lockfiles",
|
||||
downloads={
|
||||
"darwin_arm64": toolchain.Download(
|
||||
url="https://example.invalid/osv-scanner", sha256=digest
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
class FakeResp:
|
||||
def read(self):
|
||||
return payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(toolchain.urllib.request, "urlopen", lambda *a, **k: FakeResp())
|
||||
|
||||
path = toolchain.install("osv-scanner")
|
||||
assert os.access(path, os.X_OK)
|
||||
assert open(path, "rb").read() == payload
|
||||
# Installed tools are resolvable afterwards, even with an empty PATH.
|
||||
monkeypatch.setenv("PATH", "")
|
||||
monkeypatch.setattr(toolchain, "_KNOWN_DIRS", ())
|
||||
assert toolchain.resolve("osv-scanner") == path
|
||||
Reference in New Issue
Block a user