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
2 changes: 2 additions & 0 deletions cli/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,8 @@ def agent_preset_overview(self) -> str:
continue
marker = "●" if preset.id == current else "○"
face = ", ".join(preset.tools) if preset.tools is not None else "all tools"
if preset.disallowed_tools:
face = f"{face} except {', '.join(preset.disallowed_tools)}"
lines.append(
f" {marker} {preset.id} [{preset.trust}] — "
f"{preset.description or preset.display_name} ({face})"
Expand Down
2 changes: 2 additions & 0 deletions cli/tui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,8 @@ async def _preset_via_picker(app) -> str | None:
)
continue
face = ", ".join(preset.tools) if preset.tools is not None else "all tools"
if preset.disallowed_tools:
face = f"{face} except {', '.join(preset.disallowed_tools)}"
detail = (
f"[{preset.trust}] {preset.description or preset.display_name} ({face})"
)
Expand Down
85 changes: 76 additions & 9 deletions core/agent_presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class AgentPreset:
prompt: str = ""
prompt_mode: str = PROMPT_MODE_APPEND
tools: tuple[str, ...] | None = None
disallowed_tools: tuple[str, ...] | None = None
allow_spawn: bool | None = None
suggested_model: str | None = None
order: int | None = None
Expand All @@ -116,6 +117,7 @@ def snapshot(self) -> AgentPresetSnapshot:
prompt_mode=self.prompt_mode,
tools=self.tools,
allow_spawn=self.allow_spawn,
disallowed_tools=self.disallowed_tools,
)


Expand All @@ -127,6 +129,7 @@ class AgentPresetSnapshot:
prompt: str = ""
prompt_mode: str = PROMPT_MODE_APPEND
tools: tuple[str, ...] | None = None
disallowed_tools: tuple[str, ...] | None = None
allow_spawn: bool | None = None

def to_metadata(self) -> dict[str, Any]:
Expand All @@ -135,6 +138,11 @@ def to_metadata(self) -> dict[str, Any]:
"prompt": self.prompt,
"promptMode": self.prompt_mode,
"tools": list(self.tools) if self.tools is not None else None,
"disallowedTools": (
list(self.disallowed_tools)
if self.disallowed_tools is not None
else None
),
"allowSpawn": self.allow_spawn,
}

Expand All @@ -152,19 +160,31 @@ def from_metadata(cls, value: Any) -> AgentPresetSnapshot | None:
tools: tuple[str, ...] | None = None
if isinstance(raw_tools, list):
tools = tuple(str(name) for name in raw_tools)
raw_disallowed = value.get("disallowedTools")
disallowed_tools: tuple[str, ...] | None = None
if isinstance(raw_disallowed, list):
disallowed_tools = tuple(str(name) for name in raw_disallowed)
mode = value.get("promptMode")
allow_spawn = value.get("allowSpawn")
return cls(
id=value["id"],
prompt=str(value.get("prompt") or ""),
prompt_mode=(mode if mode in _PROMPT_MODES else PROMPT_MODE_APPEND),
tools=tools,
disallowed_tools=disallowed_tools,
allow_spawn=allow_spawn if isinstance(allow_spawn, bool) else None,
)

def fingerprint(self) -> tuple[Any, ...]:
"""Hashable identity for runtime-key comparison."""
return (self.id, self.prompt, self.prompt_mode, self.tools, self.allow_spawn)
return (
self.id,
self.prompt,
self.prompt_mode,
self.tools,
self.disallowed_tools,
self.allow_spawn,
)

def compose_system_prompt(self, base: str) -> str:
if not self.prompt:
Expand All @@ -175,13 +195,23 @@ def compose_system_prompt(self, base: str) -> str:
return f"{base}\n\n## Persona\n{self.prompt}"

def tool_filter(self) -> Any | None:
"""AgentRunSpec-contract narrowing filter, or None when unrestricted."""
if self.tools is None:
"""AgentRunSpec-contract narrowing filter, or None when unrestricted.

``tools`` is an allowlist; ``disallowed_tools`` subtracts from it —
or applies alone when no allowlist is present — matching the
cross-product ``disallowedTools`` frontmatter dialect.
"""
if self.tools is None and self.disallowed_tools is None:
return None
allowed = frozenset(self.tools)
allowed = frozenset(self.tools) if self.tools is not None else None
denied = frozenset(self.disallowed_tools or ())

def preset_filter(names: tuple[str, ...]) -> tuple[str, ...]:
return tuple(name for name in names if name in allowed)
return tuple(
name
for name in names
if name not in denied and (allowed is None or name in allowed)
)

return preset_filter

Expand Down Expand Up @@ -323,6 +353,40 @@ def _parse_preset_file(path: Path, *, trust: str) -> AgentPreset:
broken="allow-spawn must be a boolean",
)

# ``disallowed-tools`` is a RESTRICTION, so it parses strictly like
# ``tools:`` — a malformed value marks the preset broken instead of
# silently dropping the deny list (fail closed: the agent would otherwise
# keep tools the author tried to remove). ``*`` is rejected for the same
# reason: it would mean "deny everything", which the subtraction filter
# cannot express, and the unrestricted fallback would invert the intent.
# ``max-turns``/``memory`` are deliberately NOT parsed — nothing reads
# them, and Claude Code's ``memory`` is a scope string, not a bool.
# ``permission-mode`` likewise stays out: permission remains an
# independent session-level knob (dsh's split).
raw_disallowed = data.get(
"disallowed-tools",
data.get("disallowedTools", data.get("disallowed_tools")),
)
disallowed_tools, disallowed_error = _parse_tools(
raw_disallowed, field="disallowed-tools"
)
# ``*`` must die in BOTH spellings: the bare string folds to ``None``
# (unrestricted), while ``["*"]`` would normalize to ``("*",)`` — a deny
# entry no roster ever matches, i.e. it denies nothing. Both invert the
# author's intent, so both mark the preset broken.
if (
disallowed_error is None
and raw_disallowed is not None
and (disallowed_tools is None or "*" in disallowed_tools)
):
disallowed_error = (
"disallowed-tools: '*' would deny every tool; list tools explicitly"
)
if disallowed_error is not None:
return AgentPreset(
id=preset_id, trust=trust, path=str(path), broken=disallowed_error
)

order = data.get("order")
model = data.get("model")
return AgentPreset(
Expand All @@ -334,14 +398,17 @@ def _parse_preset_file(path: Path, *, trust: str) -> AgentPreset:
prompt=text[match.end() :].strip(),
prompt_mode=mode or PROMPT_MODE_APPEND,
tools=tools,
disallowed_tools=disallowed_tools,
allow_spawn=allow_spawn,
suggested_model=str(model) if isinstance(model, str) and model else None,
order=order if isinstance(order, int) else None,
)


def _parse_tools(value: Any) -> tuple[tuple[str, ...] | None, str | None]:
"""``tools:`` field → normalized allowlist. Absent or ``*`` = unrestricted."""
def _parse_tools(
value: Any, *, field: str = "tools"
) -> tuple[tuple[str, ...] | None, str | None]:
"""Tool-name list field → normalized names. Absent or ``*`` = unrestricted."""
if value is None:
return None, None
if isinstance(value, str):
Expand All @@ -350,10 +417,10 @@ def _parse_tools(value: Any) -> tuple[tuple[str, ...] | None, str | None]:
names = [part for part in re.split(r"[,\s]+", value) if part]
elif isinstance(value, list):
if any(not isinstance(item, str) for item in value):
return None, "tools list entries must be strings"
return None, f"{field} list entries must be strings"
names = [item for item in value if item.strip()]
else:
return None, "tools must be a string or a list of strings"
return None, f"{field} must be a string or a list of strings"
normalized: list[str] = []
for name in names:
folded = normalize_tool_name(name)
Expand Down
5 changes: 5 additions & 0 deletions core/application/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ def agent_preset_view(preset) -> dict[str, Any]:
"name": preset.display_name or preset.id,
"description": preset.description,
"tools": list(preset.tools) if preset.tools is not None else None,
"disallowedTools": (
list(preset.disallowed_tools)
if preset.disallowed_tools is not None
else None
),
"broken": preset.broken,
}

Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/presets/PresetPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const reader: AgentPresetEntry = {
name: "Code reader",
description: "Read-only investigator",
tools: ["read", "grep", "glob"],
disallowedTools: null,
broken: null,
};

Expand All @@ -21,6 +22,7 @@ const damaged: AgentPresetEntry = {
name: "damaged",
description: "",
tools: null,
disallowedTools: null,
broken: "missing YAML frontmatter block",
};

Expand Down
1 change: 1 addition & 0 deletions desktop/src/generated/app-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,7 @@ export interface AgentPresetEntry {
name: string;
description: string;
tools: string[] | null;
disallowedTools: string[] | null;
broken: string | null;
}
export interface PresetCurrentResult {
Expand Down
10 changes: 10 additions & 0 deletions protocol/app-server.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5900,6 +5900,15 @@
"type": "string"
}
},
"disallowedTools": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"broken": {
"type": [
"string",
Expand All @@ -5913,6 +5922,7 @@
"name",
"description",
"tools",
"disallowedTools",
"broken"
]
},
Expand Down
17 changes: 17 additions & 0 deletions tests/app_server/test_preset_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ def _messages(sink: io.BytesIO) -> list[dict[str, Any]]:
def test_preset_roster_selection_and_lock_over_the_wire(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
# A deny-only project preset: the wire must carry the restriction, not
# claim "all tools" (the reviewer's display point, verified end to end).
project_preset = workspace / ".agents" / "presets"
project_preset.mkdir(parents=True)
(project_preset / "no-writes.md").write_text(
"---\n"
"description: no writes\n"
"disallowed-tools: write, edit\n"
"---\nYou may not write files.",
encoding="utf-8",
)
application = DeepCodeApplication.open(tmp_path / "state.sqlite3")
project = application.projects.add(str(workspace))
thread = application.threads.start(project.id, title="Preset wire test")
Expand Down Expand Up @@ -69,6 +80,12 @@ def test_preset_roster_selection_and_lock_over_the_wire(tmp_path: Path) -> None:
roster = {entry["id"]: entry for entry in responses[2]["result"]["presets"]}
assert "code-reader" in roster and roster["code-reader"]["trust"] == "system"
assert roster["code-reader"]["tools"] == ["read", "grep", "glob", "skill"]
# Presets without a deny list report null (absent), never an empty claim.
assert roster["code-reader"]["disallowedTools"] is None
# Deny-only preset: no allowlist, but the restriction is on the wire.
assert roster["no-writes"]["tools"] is None
assert roster["no-writes"]["disallowedTools"] == ["write", "edit"]
assert roster["no-writes"]["broken"] is None

assert responses[3]["result"]["agentPreset"] == "code-reader"
assert responses[4]["result"]["agentPreset"] == "code-reader"
Expand Down
Loading
Loading