---
phase: 06-agentic-integration-security
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/plugins/hookspecs.py
  - src/ztlctl/plugins/manager.py
  - src/ztlctl/services/workflow.py
  - src/ztlctl/controllers/workflow.py
  - src/ztlctl/actions/_register_core.py
  - tests/plugins/test_manager.py
  - tests/services/test_workflow.py
autonomous: true
requirements:
  - SECU-01
  - SECU-02

must_haves:
  truths:
    - "Plugin-contributed Copier workflow templates execute with unsafe=False by default, requiring explicit --force-trust to run template hooks"
    - "Plugins declare required capabilities (filesystem, network, database, git) and the host validates access at load time with audit logging"
    - "Missing capability declarations trigger a structlog warning, not a hard error"
  artifacts:
    - path: "src/ztlctl/plugins/hookspecs.py"
      provides: "declare_capabilities hookspec"
      contains: "def declare_capabilities"
    - path: "src/ztlctl/plugins/manager.py"
      provides: "Capability validation in discover_and_load + audit logging"
      contains: "_validate_capabilities"
    - path: "src/ztlctl/services/workflow.py"
      provides: "force_trust parameter threading to Copier unsafe= flag"
      contains: "force_trust"
  key_links:
    - from: "src/ztlctl/plugins/manager.py"
      to: "src/ztlctl/plugins/hookspecs.py"
      via: "hook.declare_capabilities() call"
      pattern: "declare_capabilities"
    - from: "src/ztlctl/services/workflow.py"
      to: "src/ztlctl/controllers/workflow.py"
      via: "force_trust parameter forwarded from controller to service"
      pattern: "force_trust"
---

<objective>
Add plugin security hardening: Copier trust enforcement for plugin-contributed workflow templates and plugin capability declarations with audit logging.

Purpose: As the plugin ecosystem grows, untrusted plugin templates must not execute arbitrary Copier tasks, and plugin capabilities must be declared and logged for auditability. This prevents plugin-contributed templates from running hooks without explicit user consent and provides visibility into what plugins access.

Output: `declare_capabilities` hookspec; capability validation in PluginManager; `force_trust` parameter in WorkflowService; `--force-trust` CLI flag; structlog audit logging for plugin operations.
</objective>

<execution_context>
@/Users/shparki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/shparki/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-agentic-integration-security/06-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs -->

From src/ztlctl/plugins/hookspecs.py:
```python
hookspec = pluggy.HookspecMarker("ztlctl")

class ZtlctlHookSpec:
    @hookspec(firstresult=True)
    def pre_action(self, action_name: str, kwargs: dict[str, Any]) -> ActionRejection | dict[str, Any] | None:
    @hookspec
    def post_action(self, action_name: str, kwargs: dict[str, Any], result: Any) -> None:
    @hookspec(firstresult=True)
    def get_config_schema(self) -> type[BaseModel] | None:
    @hookspec
    def initialize(self, config: BaseModel | None) -> None:
    # ... deprecated per-event hooks ...
    # ... extension contribution hooks ...
```

From src/ztlctl/plugins/manager.py:
```python
class PluginManager:
    def __init__(self) -> None:
        self._pm = pluggy.PluginManager(PROJECT_NAME)
        self._pm.add_hookspecs(ZtlctlHookSpec)
        self._loaded: bool = False

    def discover_and_load(self, *, local_dir=None, include_entrypoints=True) -> list[str]:
        if include_entrypoints:
            self._pm.load_setuptools_entrypoints("ztlctl.plugins")
            self._normalize_plugin_instances()
        if local_dir is not None:
            self._discover_local(local_dir)
        self._register_content_models()
        self._register_note_types()
        self._loaded = True
        return self.list_plugin_names()

    def get_plugins(self) -> list[object]: ...
    def _collect_contributions(self, hook_name, expected_type, *, key_fn, reserved=None) -> list: ...
```

From src/ztlctl/services/workflow.py:
```python
class WorkflowService:
    @staticmethod
    def _run_copy(vault_root: Path, choices: WorkflowChoices) -> None:
        # SECURITY: unsafe=False (default) prevents Copier template tasks
        run_copy(str(template_root), dst_path=vault_root, ..., quiet=True)

    @staticmethod
    def _run_update(vault_root: Path, choices: WorkflowChoices | None) -> tuple[str, list[str]]:
        # SECURITY: unsafe=False (default)
        run_update(dst_path=vault_root, ..., quiet=True)
```

From src/ztlctl/controllers/workflow.py:
```python
class WorkflowController(BaseController):
    # Methods that call WorkflowService static methods
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add Copier --force-trust flag and plugin template trust enforcement (SECU-01)</name>
  <files>src/ztlctl/services/workflow.py, src/ztlctl/controllers/workflow.py, src/ztlctl/actions/_register_core.py, tests/services/test_workflow.py</files>
  <read_first>
    - src/ztlctl/services/workflow.py
    - src/ztlctl/controllers/workflow.py
    - src/ztlctl/actions/_register_core.py
    - tests/services/test_workflow.py
  </read_first>
  <behavior>
    - WorkflowService.init_workflow() accepts force_trust: bool = False parameter
    - WorkflowService.update_workflow() accepts force_trust: bool = False parameter
    - Built-in templates always use unsafe=False regardless of force_trust value
    - A new WorkflowService._run_plugin_copy() method exists that passes unsafe=force_trust
    - WorkflowController.workflow_init() and workflow_update() accept and forward force_trust parameter
    - The workflow_init and workflow_update ActionDefinitions include a force_trust ActionParam (bool, default False, cli_flag=True)
  </behavior>
  <action>
**Step 1 — Write tests first (RED):**

In `tests/services/test_workflow.py`, add:

- `test_init_workflow_force_trust_default_false`: Call `WorkflowService.init_workflow(vault_root, ...)` without force_trust. Verify via mock that `_run_copy()` is called with default unsafe=False behavior (built-in templates always use unsafe=False). This test confirms the parameter exists and defaults to False.
- `test_run_plugin_copy_unsafe_false_by_default`: If `_run_plugin_copy` is added as a separate method, test that calling it with `force_trust=False` passes `unsafe=False` to Copier's `run_copy`. Mock `copier.run_copy` to inspect the `unsafe` kwarg.
- `test_run_plugin_copy_unsafe_true_with_force_trust`: Test that calling `_run_plugin_copy` with `force_trust=True` passes `unsafe=True` to Copier's `run_copy`.
- `test_builtin_templates_always_safe`: Verify that `_run_copy()` and `_run_update()` do NOT accept or use force_trust — they always use the default `unsafe=False`. This confirms built-in templates are never affected by --force-trust.

Run tests — all FAIL.

**Step 2 — Add force_trust parameter to WorkflowService:**

In `src/ztlctl/services/workflow.py`:

1. Add `force_trust: bool = False` parameter to `init_workflow()` signature. Thread it to a new `_run_plugin_copy()` static method. Built-in `_run_copy()` remains unchanged (always unsafe=False).

2. Add `force_trust: bool = False` parameter to `update_workflow()` signature. Thread it similarly.

3. Add `_run_plugin_copy()` static method:
```python
@staticmethod
def _run_plugin_copy(
    template_path: str | Path,
    vault_root: Path,
    choices: WorkflowChoices,
    *,
    force_trust: bool = False,
) -> None:
    """Run Copier for a plugin-contributed template.

    SECURITY: Plugin templates default to unsafe=False (no hook execution).
    Only when --force-trust is explicitly passed does unsafe=True allow hooks.
    Built-in templates use _run_copy() which always uses unsafe=False.
    """
    run_copy(
        str(template_path),
        dst_path=vault_root,
        answers_file=str(_ANSWERS_RELATIVE_PATH),
        data=choices.as_data(),
        defaults=True,
        overwrite=True,
        quiet=True,
        unsafe=force_trust,
    )
```

4. Existing `_run_copy()` and `_run_update()` remain UNCHANGED (always unsafe=False).

**Step 3 — Update controller and ActionDefinitions:**

In `src/ztlctl/controllers/workflow.py`: Add `force_trust: bool = False` parameter to `workflow_init()` and `workflow_update()` methods. Forward to WorkflowService.

In `src/ztlctl/actions/_register_core.py`: Add an `ActionParam(name="force_trust", type=bool, required=False, default=False, description="Allow plugin template hooks to execute (unsafe mode)", cli_flag=True)` to the `workflow_init` and `workflow_update` ActionDefinitions.

**Step 4 — Run tests (GREEN):**
`uv run pytest tests/services/test_workflow.py -x`
  </action>
  <verify>
    <automated>uv run pytest tests/services/test_workflow.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "force_trust" src/ztlctl/services/workflow.py
    - grep -q "_run_plugin_copy" src/ztlctl/services/workflow.py
    - grep -q "unsafe=force_trust" src/ztlctl/services/workflow.py
    - grep -q "force_trust" src/ztlctl/controllers/workflow.py
    - grep -q "force_trust" src/ztlctl/actions/_register_core.py
    - grep -q "test_run_plugin_copy" tests/services/test_workflow.py
    - uv run pytest tests/services/test_workflow.py -x -q passes
  </acceptance_criteria>
  <done>force_trust parameter threaded through controller and service; _run_plugin_copy uses unsafe=force_trust; built-in templates always unsafe=False; --force-trust ActionParam added to workflow ActionDefinitions; all tests pass.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add plugin capability declarations hookspec + validation + audit logging (SECU-02)</name>
  <files>src/ztlctl/plugins/hookspecs.py, src/ztlctl/plugins/manager.py, tests/plugins/test_manager.py</files>
  <read_first>
    - src/ztlctl/plugins/hookspecs.py
    - src/ztlctl/plugins/manager.py
    - src/ztlctl/plugins/_version.py
    - tests/plugins/test_manager.py
  </read_first>
  <behavior>
    - ZtlctlHookSpec.declare_capabilities hookspec exists (NOT firstresult)
    - VALID_CAPABILITIES constant = {"filesystem", "network", "database", "git"}
    - PluginManager._validate_capabilities() iterates plugins, calls declare_capabilities on each, logs warnings for missing or invalid declarations
    - Plugin declaring {"filesystem", "git"} passes validation without warnings
    - Plugin not implementing declare_capabilities triggers structlog warning "plugin.no_capabilities_declared"
    - Plugin declaring invalid capability {"quantum"} triggers structlog warning "plugin.invalid_capabilities"
    - _validate_capabilities() is called at end of discover_and_load()
    - Audit log entries use structlog with plugin name and capability fields
  </behavior>
  <action>
**Step 1 — Write tests first (RED):**

In `tests/plugins/test_manager.py`, add:

- `test_declare_capabilities_hookspec_exists`: Import `ZtlctlHookSpec`. Assert `hasattr(ZtlctlHookSpec, "declare_capabilities")`.
- `test_capability_declarations_valid`: Create a test plugin class with `@hookimpl` on `declare_capabilities` returning `{"filesystem", "git"}`. Register it. Call `_validate_capabilities()`. Assert no warnings logged (use `caplog` or structlog capture).
- `test_capability_warning_on_missing`: Create a test plugin class that does NOT implement `declare_capabilities`. Register it. Call `_validate_capabilities()`. Assert a warning log with "plugin.no_capabilities_declared" event.
- `test_capability_warning_on_invalid`: Create a test plugin class with `declare_capabilities` returning `{"quantum"}`. Register it. Call `_validate_capabilities()`. Assert a warning log with "plugin.invalid_capabilities" event.
- `test_validate_capabilities_called_in_discover`: Mock `_validate_capabilities` on PluginManager. Call `discover_and_load()`. Assert `_validate_capabilities` was called.
- `test_audit_log_entry_format`: Create a test plugin, register it with declared capabilities. Verify that audit log entries (structlog) include `plugin`, `capability` fields.

Run tests — all FAIL.

**Step 2 — Add declare_capabilities hookspec:**

In `src/ztlctl/plugins/hookspecs.py`, add in the "Extension contribution hooks" section (NOT as firstresult):

```python
@hookspec
def declare_capabilities(self) -> set[str] | None:
    """Return the set of capabilities this plugin requires.

    Valid values: ``{"filesystem", "network", "database", "git"}``.
    Missing declaration is treated as a warning (not an error) in plugin API v2.
    Returns ``None`` or does not implement to indicate no declaration.
    """
```

**Step 3 — Add capability validation to PluginManager:**

In `src/ztlctl/plugins/manager.py`:

1. Add `VALID_CAPABILITIES` constant at module level:
```python
VALID_CAPABILITIES: frozenset[str] = frozenset({"filesystem", "network", "database", "git"})
```

2. Add `_validate_capabilities()` method:
```python
def _validate_capabilities(self) -> None:
    """Check plugin capability declarations and log warnings.

    Missing declarations are warnings (not errors) in plugin API v2
    to avoid breaking existing plugins. Future API versions may enforce.
    """
    import structlog
    log = structlog.get_logger(__name__)

    for plugin in self._pm.get_plugins():
        plugin_name = self._pm.get_name(plugin) or plugin.__class__.__name__
        declare_fn = getattr(plugin, "declare_capabilities", None)
        if declare_fn is None:
            log.warning(
                "plugin.no_capabilities_declared",
                plugin=plugin_name,
            )
            continue
        try:
            caps = declare_fn()
        except Exception:
            log.warning(
                "plugin.capability_declaration_failed",
                plugin=plugin_name,
                exc_info=True,
            )
            continue
        if caps is None:
            log.warning(
                "plugin.no_capabilities_declared",
                plugin=plugin_name,
            )
            continue
        if not isinstance(caps, set):
            log.warning(
                "plugin.invalid_capabilities_type",
                plugin=plugin_name,
                returned_type=type(caps).__name__,
            )
            continue
        invalid = caps - VALID_CAPABILITIES
        if invalid:
            log.warning(
                "plugin.invalid_capabilities",
                plugin=plugin_name,
                invalid=sorted(invalid),
                valid=sorted(VALID_CAPABILITIES),
            )
        else:
            log.info(
                "plugin.capabilities_validated",
                plugin=plugin_name,
                capabilities=sorted(caps),
            )
```

3. Call `_validate_capabilities()` in `discover_and_load()`, after `self._register_note_types()` and before `self._loaded = True`:
```python
self._register_content_models()
self._register_note_types()
self._validate_capabilities()  # NEW: SECU-02
self._loaded = True
```

**Step 4 — Run tests (GREEN):**
`uv run pytest tests/plugins/test_manager.py -x`

**Step 5 — Full validation:**
`uv run ruff check . && uv run ruff format --check . && uv run mypy src/ && uv run pytest`
  </action>
  <verify>
    <automated>uv run pytest tests/plugins/test_manager.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "def declare_capabilities" src/ztlctl/plugins/hookspecs.py
    - grep -q "VALID_CAPABILITIES" src/ztlctl/plugins/manager.py
    - grep -q "_validate_capabilities" src/ztlctl/plugins/manager.py
    - grep -q "plugin.no_capabilities_declared" src/ztlctl/plugins/manager.py
    - grep -q "plugin.invalid_capabilities" src/ztlctl/plugins/manager.py
    - grep -q "plugin.capabilities_validated" src/ztlctl/plugins/manager.py
    - grep -q "test_declare_capabilities_hookspec_exists" tests/plugins/test_manager.py
    - grep -q "test_capability_warning_on_missing" tests/plugins/test_manager.py
    - uv run pytest tests/plugins/test_manager.py -x -q passes
  </acceptance_criteria>
  <done>declare_capabilities hookspec added (not firstresult); VALID_CAPABILITIES constant defined; _validate_capabilities() validates declarations and logs warnings for missing/invalid capabilities with structlog; called in discover_and_load(); all tests pass.</done>
</task>

</tasks>

<verification>
- `uv run pytest tests/services/test_workflow.py tests/plugins/test_manager.py -x` passes
- `uv run ruff check . && uv run mypy src/` passes
- `uv run pytest` full suite passes (no regressions)
- force_trust parameter threaded through workflow init/update
- declare_capabilities hookspec callable by plugins
</verification>

<success_criteria>
1. Plugin-contributed Copier templates default to unsafe=False; --force-trust overrides to unsafe=True
2. Built-in templates always use unsafe=False regardless of --force-trust
3. declare_capabilities hookspec exists and is NOT firstresult
4. Missing capability declarations log a structlog warning (not error)
5. Invalid capabilities log a structlog warning with valid list
6. _validate_capabilities() runs during discover_and_load()
7. All tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/06-agentic-integration-security/06-03-SUMMARY.md`
</output>
