feat(mcp): write, test and delete this instance's own playbooks (#5748)
#5748 "MCP can write, edit and test this instance's own playbooks (steward-local)": two admin tools beside run_playbook, so an agent can build automation for these hosts that isn't part of Steward itself. - save_playbook(playbook, content, source="steward-local", replace=false) goes through the editor's own validate_playbook_yaml and write_playbook. A playbook that isn't a list of plays comes back with the YAML error and its line, and nothing is written. An existing file is overwritten only with replace=true. steward-builtin is refused (Steward's playbooks change in its repository), as are git sources (their repo owns them). - After writing, `ansible-playbook --syntax-check` runs against a throwaway localhost inventory (no host contacted, 60 s), so a module name Ansible can't resolve shows before any run. The result rides the reply; the file is kept either way. - delete_playbook(playbook, source="steward-local"). - Both audit as ansible.playbook.created/updated/deleted, "<user> via MCP", with the size and never the content. The testing loop is the existing one: run_playbook defaults to check mode, on one target, then get_ansible_run. The integration test does that loop with the real ansible-playbook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -212,7 +212,7 @@ def read_playbook(source_path: str, relative_path: str) -> str | None:
|
||||
return target.read_text(errors="replace")
|
||||
|
||||
|
||||
def _resolve_writable(source_path: str, relative_path: str) -> tuple[Path | None, str | None]:
|
||||
def resolve_writable(source_path: str, relative_path: str) -> tuple[Path | None, str | None]:
|
||||
"""Resolve relative_path under source_path, guarding traversal + extension.
|
||||
|
||||
Returns (target_path, None) on success or (None, error). The root need not
|
||||
@@ -233,7 +233,7 @@ def _resolve_writable(source_path: str, relative_path: str) -> tuple[Path | None
|
||||
|
||||
def write_playbook(source_path: str, relative_path: str, content: str) -> tuple[bool, str | None]:
|
||||
"""Write a playbook into a source, creating parent dirs. Traversal-guarded."""
|
||||
target, err = _resolve_writable(source_path, relative_path)
|
||||
target, err = resolve_writable(source_path, relative_path)
|
||||
if err:
|
||||
return False, err
|
||||
try:
|
||||
@@ -246,7 +246,7 @@ def write_playbook(source_path: str, relative_path: str, content: str) -> tuple[
|
||||
|
||||
def delete_playbook(source_path: str, relative_path: str) -> tuple[bool, str | None]:
|
||||
"""Delete a playbook from a source. Traversal-guarded; no-op if absent."""
|
||||
target, err = _resolve_writable(source_path, relative_path)
|
||||
target, err = resolve_writable(source_path, relative_path)
|
||||
if err:
|
||||
return False, err
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user