Skip to content
Merged
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
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# AGENTS.md — amplifier-app-cli

## Filesystem write-permission projection

Directory commands persist write policy under `tool-filesystem`. During config
resolution, project only its operator write grants and denies onto declared
`tool-apply-patch` entries; never copy bundle filesystem defaults, read policy,
source, or engine. Validate both shared lists before granting anything. Preserve
explicit empty patch allowlists and the module's omitted-policy default scope.
Shared filesystem `denied_write_paths: null` means no additional denies, matching
that consumer; preserve it without forwarding null or clearing patch denies.
Append grants to the portable `additional_allowed_write_paths` field, not
`allowed_write_paths`; union denies into `denied_write_paths`. Validate all three
present patch policy fields as lists of strings whenever sharing nonempty write
policy. Do not materialize the patch default or working directory during assembly:
the module selects its default only at mount, after session.working_dir is available
for root, child, and resumed sessions. Explicit [] gains only the extra grants.
Publish upstream module support for this field before shipping the CLI projection;
older module versions do not consume it. Keep generic tool-overlay merge semantics
unchanged: this new field is replaced, not automatically unioned, by an overlay.
Run `tests/test_patch_write_permissions.py` and `tests/test_merge_utils.py`.
This assembly step does not update already-mounted tools.

## Boundary rule: `amplifier_app_cli/data/` vs. an external bundle

`amplifier_app_cli/data/` ships inside the CLI's own wheel — anything placed there loads
Expand Down
78 changes: 70 additions & 8 deletions amplifier_app_cli/runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,14 +266,9 @@ def _on_progress(action: str, detail: str) -> None:
session_id=session_id, project_slug=project_slug
)
if tool_overrides:
if bundle_config.get("tools"):
# Bundle has tools - merge overrides with existing
bundle_config["tools"] = _apply_tool_overrides(
bundle_config["tools"], tool_overrides
)
else:
# Bundle has no tools - use overrides directly
bundle_config["tools"] = tool_overrides
bundle_config["tools"] = _apply_tool_overrides(
bundle_config.get("tools") or [], tool_overrides
)
elif bundle_config.get("tools"):
# No user overrides, but still apply CLI tool policies
# (e.g., cwd in write paths, default skills dirs)
Expand Down Expand Up @@ -997,6 +992,9 @@ def _apply_tool_overrides(
Permission fields (allowed_write_paths, allowed_read_paths) are UNIONED
rather than replaced, so session-scoped paths ADD to bundle defaults.

Operator tool-filesystem write permissions also extend declared
tool-apply-patch entries, without copying other filesystem configuration.

Policy: Current working directory (".") is always included in allowed_write_paths
for tool-filesystem, ensuring users can always write within their project.
"""
Expand Down Expand Up @@ -1034,9 +1032,73 @@ def _apply_tool_overrides(
):
result.append(override)

result = _share_filesystem_write_permissions(
result, override_map.get("tool-filesystem", {})
)
return _ensure_cli_tool_policies(result)


def _share_filesystem_write_permissions(
tools: list[dict[str, Any]], filesystem_override: dict[str, Any]
) -> list[dict[str, Any]]:
"""Share operator write paths, deferring patch default scope until mount."""
filesystem_config = filesystem_override.get("config") or {}
if not isinstance(filesystem_config, dict):
return tools
# Validate the whole shared policy before applying any grants. Ignoring a
# malformed deny list could otherwise leave its matching grant unrestricted.
for field in ("allowed_write_paths", "denied_write_paths"):
if field in filesystem_config:
paths = filesystem_config[field]
# The filesystem consumer treats null denies as no explicit denials.
# Preserve that contract without passing null to the patch module.
if field == "denied_write_paths" and paths is None:
continue
if not isinstance(paths, list) or any(
not isinstance(path, str) for path in paths
):
raise ValueError(
f"tool-filesystem {field} must be a list of path strings"
)
shared = {
field: paths
for field in ("allowed_write_paths", "denied_write_paths")
if (paths := filesystem_config.get(field))
}
if not shared:
return tools

result = []
for tool in tools:
if isinstance(tool, dict) and tool.get("module") == "tool-apply-patch":
tool = tool.copy()
config = (tool.get("config") or {}).copy()
for field in (
"allowed_write_paths",
"denied_write_paths",
"additional_allowed_write_paths",
):
if field in config:
paths = config[field]
if not isinstance(paths, list) or any(
not isinstance(path, str) for path in paths
):
raise ValueError(
f"tool-apply-patch {field} must be a list of path strings"
)
config[field] = paths.copy()
for field, shared_paths in shared.items():
if field == "allowed_write_paths":
# The module knows each session's working directory at mount.
# Do not freeze an omitted allowlist or expand explicit [] here.
field = "additional_allowed_write_paths"
paths = config.get(field, [])
config[field] = list(dict.fromkeys([*paths, *shared_paths]))
tool["config"] = config
result.append(tool)
return result


def _ensure_cli_tool_policies(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Apply all CLI policy injections to tool configs.

Expand Down
Loading
Loading