# Story 3.5: Secure Default for Plugin Loading

**Status:** Complete
**Priority:** High
**Depends on:** None

---

## Context / Background

The GPT-5.2 audit identified a security risk in the plugin loading system: entry point plugins can load arbitrary code, and the default configuration is permissive.

**Current state in `src/fapilog/core/settings.py:1027-1042`:**

```python
class PluginsSettings(BaseModel):
    enabled: bool = Field(default=True, description="Enable plugin loading")
    allowlist: list[str] = Field(
        default_factory=list,  # Empty = allow ALL
        description="If non-empty, only these plugin names are allowed",
    )
    denylist: list[str] = Field(
        default_factory=list,
        description="Plugin names to block from loading",
    )
```

**Current behavior in `src/fapilog/__init__.py:71-81`:**

```python
def _plugin_allowed(name: str, settings: _Settings) -> bool:
    allow = (
        {_normalize(n) for n in settings.plugins.allowlist}
        if settings.plugins.allowlist
        else None  # Empty allowlist = allow everything
    )
    deny = {_normalize(n) for n in settings.plugins.denylist}
    n = _normalize(name)
    if allow is not None and n not in allow:
        return False
    ...
```

**Problem:**

- With empty `allowlist` (default), ANY plugin registered via entry points can be loaded
- `enabled=True` by default means plugin loading is active out of the box
- Malicious packages could register entry points that execute arbitrary code
- Users may not realize they're exposed to this risk

**Risk scenario:**

1. User installs a compromised package that registers a `fapilog.sinks` entry point
2. User configures `core.sinks = ["malicious-sink"]` or it's added via typosquatting
3. Malicious code executes during plugin loading

---

## Scope (In / Out)

### In Scope

- Change default behavior to only allow built-in plugins
- Add `allow_external: bool` setting (default False)
- Document security implications of enabling external plugins
- Provide clear migration path for existing users

### Out of Scope

- Plugin signing/verification (future Story 3.4c covers this for releases)
- Entry point namespace isolation
- Sandboxed plugin execution

---

## Acceptance Criteria

### AC1: Built-ins Only by Default

**Description:** By default, only built-in plugins are loadable; entry point plugins are blocked.

**Validation:**

```python
from fapilog.core.settings import Settings

s = Settings()
assert s.plugins.allow_external is False

# Built-in plugins still work
from fapilog.plugins.loader import load_plugin
sink = load_plugin("fapilog.sinks", "file", {})  # OK

# Entry point plugins blocked by default
# (if a hypothetical "external-sink" were registered)
# load_plugin("fapilog.sinks", "external-sink", {})  # Blocked
```

### AC2: Explicit Opt-In for External Plugins

**Description:** Users can enable external plugins with explicit configuration.

**Validation:**

```python
# Via settings
s = Settings(plugins={"allow_external": True})
assert s.plugins.allow_external is True

# Via environment variable
# FAPILOG__PLUGINS__ALLOW_EXTERNAL=true

# Via allowlist (implicit opt-in for specific plugins)
s = Settings(plugins={"allowlist": ["my-custom-sink"]})
# External "my-custom-sink" is allowed
```

### AC3: Warning on External Plugin Load

**Description:** When external plugins are loaded, a diagnostic warning is emitted.

**Validation:**

```python
# With allow_external=True or explicit allowlist
# Loading an entry point plugin emits:
# "Loading external plugin 'foo' from entry point.
#  External plugins execute arbitrary code."
```

### AC4: Documentation Updated

**Description:** Security implications are documented in user guide.

**Validation:**

```markdown
# In docs/user-guide/plugins.md or similar

## Security

By default, fapilog only loads built-in plugins. To use external plugins:

1. **Recommended:** Use `allowlist` to specify exactly which plugins are permitted
2. **Less secure:** Set `allow_external: true` to permit any entry point plugin

External plugins can execute arbitrary code. Only enable plugins you trust.
```

---

## Implementation Notes

### File Changes

```text
src/fapilog/core/settings.py (MODIFIED - add allow_external field)
src/fapilog/__init__.py (MODIFIED - enforce allow_external)
src/fapilog/plugins/loader.py (MODIFIED - add external plugin warning)
docs/user-guide/plugins.md (MODIFIED - security section)
```

### Settings Change

```python
class PluginsSettings(BaseModel):
    enabled: bool = Field(default=True, description="Enable plugin loading")
    allow_external: bool = Field(
        default=False,
        description="Allow loading plugins from entry points (security risk)",
    )
    allowlist: list[str] = Field(
        default_factory=list,
        description="If non-empty, only these plugin names are allowed (implies allow_external for listed plugins)",
    )
    denylist: list[str] = Field(
        default_factory=list,
        description="Plugin names to block from loading",
    )
```

### Loader Change

```python
def load_plugin(group: str, name: str, config: dict, *, settings: Settings | None = None) -> Any:
    # ... existing built-in lookup ...

    # Entry point discovery
    if not settings.plugins.allow_external:
        if not settings.plugins.allowlist or name not in settings.plugins.allowlist:
            raise PluginNotFoundError(
                f"Plugin '{name}' not found in built-ins. "
                f"External plugins disabled. Set plugins.allow_external=true "
                f"or add '{name}' to plugins.allowlist."
            )

    # Warn on external load
    diagnostics.warn("plugins", "loading external plugin", name=name, group=group)

    # ... existing entry point loading ...
```

---

## Tasks

### Phase 1: Settings

- [ ] Add `allow_external` field to `PluginsSettings`
- [ ] Default to `False`
- [ ] Add environment variable support

### Phase 2: Enforcement

- [ ] Update `_plugin_allowed()` to check `allow_external`
- [ ] Treat explicit `allowlist` as opt-in for those specific plugins
- [ ] Add diagnostic warning on external plugin load

### Phase 3: Documentation

- [ ] Document security implications
- [ ] Add migration guide for users with external plugins
- [ ] Update CHANGELOG

---

## Tests

### Unit Tests

- `tests/unit/test_plugin_security.py`
  - Test built-in plugins load with default settings
  - Test entry point plugins blocked by default
  - Test `allow_external=True` enables entry points
  - Test explicit `allowlist` allows specific external plugins
  - Test warning emitted on external load

---

## Definition of Done

### Code Complete

- [x] `allow_external` setting added
- [x] Default blocks external plugins
- [x] Explicit opt-in works
- [x] Warnings emitted

### Quality Assurance

- [x] All tests pass
- [x] `ruff check` passes
- [x] `mypy` passes

### Documentation

- [x] Security documentation added
- [x] Migration guide provided
- [x] CHANGELOG updated

---

## Risks / Rollback

### Risks

1. **Risk:** Breaking change for users with external plugins
   - **Mitigation:** Clear error message with instructions; migration guide

2. **Risk:** Users disable security without understanding implications
   - **Mitigation:** Warning in docs; diagnostic on external load

### Rollback Plan

If issues occur:

1. Change `allow_external` default back to `True`
2. Keep infrastructure for future tightening

---

## Related Stories

- **Related:** Story 3.4c - Supply-chain security (CI/build-time)
- **Related:** Story 4.36 - Plugin validation at load time

---

## Code Review

**Date:** 2026-01-16
**Reviewer:** Claude
**Verdict:** OK to PR

### Summary

Reviewed implementation of secure-by-default plugin loading. External plugins blocked by default, with explicit opt-in via `allow_external=True` or `allowlist`. Comprehensive tests (13 new), 100% diff coverage.

### AC Verification

| Criterion | Evidence |
|-----------|----------|
| AC1: Built-ins Only by Default | `settings.py:1004-1007` - `allow_external=False`; `loader.py:312-320` - blocks entry points |
| AC2: Explicit Opt-In | `loader.py:227-245` - `_is_external_allowed()` checks both options |
| AC3: Warning on External Load | `loader.py:329-337` - `diagnostics.warn()` with name/group |
| AC4: Documentation Updated | `configuration.md:194-247` - Security section with migration guide |

### Quality Gates

- [x] ruff check passed
- [x] ruff format passed
- [x] mypy passed
- [x] diff-cover >= 90% (100%)
- [x] No weak assertions
- [x] No dead code

---

## Change Log

| Date       | Change        | Author |
|------------|---------------|--------|
| 2026-01-16 | Initial draft | Claude |
| 2026-01-16 | Code review passed | Claude |
