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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ template line.
| *(secret file)* | `openrouter_api_key` | `str` | `""` | `OPENROUTER_API_KEY` |
| `gate.never` | `gate_never` | `tuple` | `()` | — |
| `gate.approved` | `gate_approved` | `tuple` | `()` | — |
| `gate.ask_when` | `gate_ask_when` | `dict` | `{}` | — |
| `gate.announce` | `gate_announce` | `bool` | `True` | — |
| `budget.cap_usd` | `budget_cap_usd` | `float` | `0.0` (unlimited) | — |
| `budget.warn_ratio` | `budget_warn_ratio` | `float` | `0.8` | — |
Expand Down Expand Up @@ -95,6 +96,34 @@ Notes on specific keys:
env first, then `secrets/<name>`. Empty `web_password` means the web UI fails
closed and no listener is built.
- **`gate.approved` accepts `"*"`** to approve every tool; `gate.never` still wins.
- **`gate.ask_when` cards an approved tool when a named argument is present** —
for tools that are two actions in one. hound's `smart_fetch` reads a page, but
the same call carrying `actions` clicks and submits on it:

```yaml
gate:
approved: [mcp_hound_smart_fetch]
ask_when:
mcp_hound_smart_fetch: [actions]
```

Matching is on presence, not value, and it outranks every auto-approve —
`"*"`, an explicit approve, and the read-only fast path. `gate.never` still
wins. Answering **always** to such a card persists `mcp_hound_smart_fetch:actions`
(tool + the argument that raised it) rather than the bare tool name, so one
tap never approves the tool's other watched arguments.

Two consequences of matching on presence alone. **One "always" covers every
future value of that argument** — granting `actions` once approves every later
call carrying `actions`, whatever it clicks; the gate does not read what is
inside. And the grant *is* the approval: a tool that is otherwise unapproved
becomes callable **only** for calls carrying a granted argument, and still
cards for anything else.

The argument name is matched verbatim against the call's arguments and is
**not validated against the tool's schema** — a typo names an argument that
never arrives, silently leaving the tool on whatever its ordinary listing
says. Check the spelling against the tool's parameters.
- **`imessage.enabled` also requires `sys.platform == "darwin"`.**
- **`imessage.mode` picks which Apple ID chief speaks as** — `self` (default,
today's install: the owner's own, chief texted through the self-chat) or
Expand Down Expand Up @@ -164,7 +193,7 @@ hand-edit config from a package install; use `config_apply`.
| `chief.db` | sqlite sessions + transcripts; schema created at boot, **no migrations** |
| `chief.sock` | unix socket for `chief-cli` |
| `audit.jsonl` | every gated tool call |
| `gate_approved.json` | persisted "always allow" set, minus config-approved |
| `gate_approved.json` | persisted "always allow" set, minus config-approved; a `tool:argument` entry is an `ask_when` grant |
| `installed.yaml` | package install registry |
| `packages/` | clone of `packages_repo`, pulled by `chief-pkg update` |
| `hooks/<package>/` | per-package hook scratch state |
Expand Down
31 changes: 23 additions & 8 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,40 @@ Three sender values matter (`dispatch.py`): `owner`, `system`, and anything else

## The gate — `gate.py`

**Gating is by tool name only.** Arguments are never inspected for the decision;
they are only rendered into announcement and card text. A tool approved once with
"always" is approved for every argument it will ever receive — an approved `shell`
is an approved *anything*.
**Gating is by tool name, plus `ask_when`'s argument *presence*.** Argument
*values* are never inspected for the decision; they are only rendered into
announcement and card text. Outside an `ask_when` rule, a tool approved once
with "always" is approved for every argument it will ever receive — an approved
`shell` is an approved *anything*.

`GatedTools.dispatch` decides in strict order:

1. **Unknown tool name** → record `unknown_tool`, pass through to the registry for
a helpful error. **A hallucinated name must never raise a card**, and must
never be able to persist an "always" for a tool that doesn't exist.
2. **`policy.decide(name)`** — `never` → NEVER; `"*"` or an exact name in
`approved` → APPROVED; else ASK. **`never` is checked first and beats the `"*"`
wildcard.**
3. **ASK + `read_only`** → auto-approved and announced.
2. **`policy.decide(name, arguments)`** — `never` → NEVER. Then, if the call
carries any argument named by that tool's `ask_when` rule, **the call is
decided by its grants alone**: every carried watched argument holding a
`tool:argument` grant → APPROVED, otherwise ASK. The bare name is neither
required nor sufficient there. Only when no watched argument is present do
`"*"` or an exact name in `approved` → APPROVED; else ASK. **`never` is
checked first and beats everything; `ask_when` beats `"*"` and an explicit
approve.**
3. **ASK + `read_only`** → auto-approved and announced — *unless* the call has
pending watched arguments. **`ask_when` outranks `read_only`**: the tool may
only read, but the argument is what makes it act.
4. **ASK otherwise** → approval card. Deliberately *not* announced — the card
already shows the call, and a second line would double it.
5. **NEVER** → returns an error *string* to the model, not an exception. The turn
continues and the model sees the denial.

**"always" on an `ask_when` card grants `tool:argument`, not the tool.** That is
why step 2 checks grants before the bare name: a composite grant only lifts the
veto, so consulting the bare name first would make "always" a no-op that re-cards
forever on a tool nothing else approves. The grant is still narrow — it never
approves a call that doesn't carry that argument, and never covers a sibling
argument.

`ASK` is never terminal; it always resolves to NEVER or APPROVED.

### read_only is a narrow promise
Expand Down
17 changes: 17 additions & 0 deletions src/chief/config/coerce.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ def as_handles(value: Any) -> tuple[str, ...]:
)


def ask_when(raw: Any) -> dict[str, tuple[str, ...]]:
"""Coerce ``gate.ask_when`` to ``tool name -> watched argument names``.

Values take the same shapes :func:`as_handles` accepts, so a single
argument needs no list (``fetch: actions``).
"""
if not raw:
return {}
if not isinstance(raw, dict):
raise ConfigError(
"gate.ask_when must be a mapping of tool name -> argument names "
f"(e.g. {{mcp_hound_smart_fetch: [actions]}}), got "
f"{type(raw).__name__} {raw!r}"
)
return {str(tool): as_handles(args) for tool, args in raw.items()}


def autonomy(value: Any) -> str:
"""Coerce ``update.autonomy``; reject anything outside the three values.

Expand Down
1 change: 1 addition & 0 deletions src/chief/config/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ def load_config(path: Path = Path("config.yaml")) -> Config:
provider_aliases=coerce.aliases(raw.get("provider_aliases") or {}),
gate_never=tuple(gate.get("never") or ()),
gate_approved=tuple(gate.get("approved") or ()),
gate_ask_when=coerce.ask_when(gate.get("ask_when")),
gate_announce=bool(gate.get("announce", True)),
budget_cap_usd=float(budget.get("cap_usd", 0.0)),
budget_warn_ratio=float(budget.get("warn_ratio", 0.8)),
Expand Down
4 changes: 4 additions & 0 deletions src/chief/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ class Config:
provider_aliases: dict[str, "AliasSpec"] = field(default_factory=dict)
gate_never: tuple[str, ...] = ()
gate_approved: tuple[str, ...] = ()
# Tool name -> argument names that force an approval card even when the
# tool is approved, for tools that are two actions in one (hound's
# smart_fetch reads a page; the same call carrying `actions` clicks on it).
gate_ask_when: dict[str, tuple[str, ...]] = field(default_factory=dict)
# Announce every non-card tool call on the session's own surface, so an
# approved / "always allow"ed tool stays visible instead of silent.
gate_announce: bool = True
Expand Down
82 changes: 25 additions & 57 deletions src/chief/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@
NEVER and APPROVED lists decide most calls; read-only tools auto-approve; the
remaining gray zone raises an approval card on the session's own surface. From
a card the owner can approve once or "always allow" a tool, which persists it
to the approved set (#187). Every call that does *not* raise a card is instead
announced on the session's surface as it starts, so an approved or "always"
tool stays visible to the owner instead of running silently. Everything
behavioral lives in prompts — this file only enforces.
to the approved set (#187). A ``gate.ask_when`` argument pulls an otherwise
approved tool back into the card path — see :mod:`chief.gate_policy` for the
rules and for what "always" persists there. Every call that does *not* raise a
card is instead announced on the session's surface as it starts, so an approved
or "always" tool stays visible to the owner instead of running silently.
Everything behavioral lives in prompts — this file only enforces.
"""

import json
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path

from chief.approvals import Approval, ApprovalBroker
from chief.audit import AuditLog
from chief.dispatch import WEB_CHANNEL, Dispatcher
from chief.gate_policy import Decision, GatePolicy, grant_key
from chief.provider.base import ToolCall, ToolSpec
from chief.tools import ToolContext, ToolDispatcher

Expand All @@ -29,37 +29,6 @@
ANNOUNCE_ARG_LIMIT = 160


class Decision(Enum):
NEVER = "never"
APPROVED = "approved"
ASK = "ask"


@dataclass
class GatePolicy:
"""The code-enforced lists; anything on neither list asks.

``approved`` is a live set — an "always allow" answer adds to it so the
tool stops asking for the rest of the process (and is persisted so it
survives a restart). A ``"*"`` entry in ``approved`` matches every tool
name (the config-driven "all tools" switch); ``never`` still takes
precedence over it.
"""

never: frozenset[str] = frozenset()
approved: set[str] = field(default_factory=set)

def decide(self, tool_name: str) -> Decision:
if tool_name in self.never:
return Decision.NEVER
if "*" in self.approved or tool_name in self.approved:
return Decision.APPROVED
return Decision.ASK

def allow_always(self, tool_name: str) -> None:
self.approved.add(tool_name)


AskApproval = Callable[[ToolContext, str], Awaitable[Approval]]
AllowAlways = Callable[[str], None]
Announce = Callable[[ToolContext, str], Awaitable[None]]
Expand Down Expand Up @@ -100,19 +69,6 @@ def announce_text(call: ToolCall, *, denied: bool = False) -> str:
return f"⚙ {call.name} {arguments}{suffix}"


def load_approved(path: Path) -> set[str]:
"""Read the persisted "always allow" tool names (empty if absent)."""
if not path.exists():
return set()
return set(json.loads(path.read_text()))


def save_approved(names: set[str], path: Path) -> None:
"""Persist the "always allow" tool names, sorted for a stable file."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(sorted(names)))


class GatedTools:
"""Per-session tool dispatcher: gate + audit around the shared registry."""

Expand Down Expand Up @@ -148,14 +104,17 @@ async def dispatch(
# self-correct. Nothing runs, so nothing is announced either.
self._record(call, "unknown_tool")
return await self._registry.dispatch(call, self._context)
decision = self._policy.decide(call.name)
if decision is Decision.ASK and call.name in self._read_only():
decision = self._policy.decide(call.name, call.arguments)
# A watched argument outranks every auto-approve, read_only included:
# the tool may only read, but the argument is what makes it act.
pending = self._policy.pending_arguments(call.name, call.arguments)
if decision is Decision.ASK and not pending and call.name in self._read_only():
decision = Decision.APPROVED
self._record(call, "read_only")
await self._announce_call(call, denied=False)
elif decision is Decision.ASK:
# The card already shows the call; a second line would double it.
decision = await self._ask_card(call)
decision = await self._ask_card(call, pending)
else:
self._record(call, f"list:{decision.value}")
await self._announce_call(call, denied=decision is Decision.NEVER)
Expand All @@ -177,11 +136,20 @@ async def _announce_call(self, call: ToolCall, *, denied: bool) -> None:
except Exception:
logger.exception("failed to announce tool call %s", call.name)

async def _ask_card(self, call: ToolCall) -> Decision:
question = f"approve tool call {call.name}({call.arguments})? yes / always / no"
async def _ask_card(
self, call: ToolCall, pending: tuple[str, ...] = ()
) -> Decision:
carries = f" — carries {', '.join(pending)}" if pending else ""
question = (
f"approve tool call {call.name}({call.arguments})"
f"{carries}? yes / always / no"
)
answer = await self._ask(self._context, question)
if answer is Approval.ALWAYS:
self._on_always(call.name)
# An ask_when card grants the argument that raised it, not the
# whole tool — otherwise one tap would approve every other use.
for grant in [grant_key(call.name, arg) for arg in pending] or [call.name]:
self._on_always(grant)
self._record(call, f"card:{answer.value}")
return Decision.NEVER if answer is Approval.DENY else Decision.APPROVED

Expand Down
111 changes: 111 additions & 0 deletions src/chief/gate_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""What the gate's lists decide, and how "always allow" is persisted.

Split from :mod:`chief.gate` so that file stays the enforcement path (cards,
announcements, audit) and this one holds the decision rules and their storage.

Two stores feed one effective approved set: ``gate.approved`` in config.yaml is
the owner's declared intent, and ``gate_approved.json`` accretes "always allow"
answers. They are unioned at boot (:func:`chief.wiring.build_gate`), which is
what lets a tap take effect without ever rewriting the owner's config.
"""

import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any

# An argument-scoped grant is one approved-set entry, so "always allow" on an
# ask_when card persists through the same file and the same code path as any
# other. Tool names are identifiers or "mcp_<server>_<tool>", so ":" cannot
# collide with a bare name.
GRANT_SEPARATOR = ":"


def grant_key(tool_name: str, argument: str) -> str:
"""The approved-set entry standing for "this tool, carrying this argument"."""
return f"{tool_name}{GRANT_SEPARATOR}{argument}"


class Decision(Enum):
NEVER = "never"
APPROVED = "approved"
ASK = "ask"


@dataclass
class GatePolicy:
"""The code-enforced lists; anything on neither list asks.

``approved`` is a live set — an "always allow" answer adds to it so the
tool stops asking for the rest of the process (and is persisted so it
survives a restart). A ``"*"`` entry in ``approved`` matches every tool
name (the config-driven "all tools" switch); ``never`` still takes
precedence over it.

``ask_when`` maps a tool name to argument names that pull it back into the
card path *however* it was approved. It exists because one tool can be two
actions: hound's ``smart_fetch`` reads a page, but the same call carrying
``actions`` clicks and submits on it. Answering "always" to such a card
grants ``tool:argument`` (see :func:`grant_key`) rather than the bare name,
so the owner never has to approve the whole tool to allow one argument.
"""

never: frozenset[str] = frozenset()
approved: set[str] = field(default_factory=set)
ask_when: Mapping[str, Sequence[str]] = field(default_factory=dict)

def decide(
self, tool_name: str, arguments: Mapping[str, Any] | None = None
) -> Decision:
if tool_name in self.never:
return Decision.NEVER
if self._watched_present(tool_name, arguments):
# A call carrying a watched argument is decided by its grants
# alone — the bare name is neither required nor sufficient. This
# must come *before* the bare-name branch: a composite grant only
# lifts the veto, so falling through would leave "always" a no-op
# that re-cards forever on a tool nothing else approves.
if self.pending_arguments(tool_name, arguments):
return Decision.ASK
return Decision.APPROVED
if "*" in self.approved or tool_name in self.approved:
return Decision.APPROVED
return Decision.ASK

def _watched_present(
self, tool_name: str, arguments: Mapping[str, Any] | None
) -> tuple[str, ...]:
"""The watched argument names this call actually carries."""
watched = self.ask_when.get(tool_name) or ()
return tuple(name for name in watched if name in (arguments or {}))

def pending_arguments(
self, tool_name: str, arguments: Mapping[str, Any] | None = None
) -> tuple[str, ...]:
"""Watched arguments this call carries that have no standing grant.

Non-empty means the call must be carded no matter what the lists say.
"""
return tuple(
name
for name in self._watched_present(tool_name, arguments)
if grant_key(tool_name, name) not in self.approved
)

def allow_always(self, tool_name: str) -> None:
self.approved.add(tool_name)


def load_approved(path: Path) -> set[str]:
"""Read the persisted "always allow" entries (empty if absent)."""
if not path.exists():
return set()
return set(json.loads(path.read_text()))


def save_approved(names: set[str], path: Path) -> None:
"""Persist the "always allow" entries, sorted for a stable file."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(sorted(names)))
Loading
Loading