docs/adr/0014-security-module.md

# ADR-0014: Centralized security module

## Status

Proposed

## Context

Security validation logic is currently distributed across multiple modules:

- `Conjure.Executor.Local` - Path validation in `validate_path/2`
- `Conjure.Executor.Docker` - Path escaping in `escape_for_shell/1`, `escape_for_python/1`
- `Conjure.ExecutionContext` - `allowed_paths` field definition
- Various inline checks throughout the codebase

The specification (section 15.3) defines a `Conjure.Security` module that was not implemented:

```elixir
defmodule Conjure.Security do
  def validate_path(path, allowed_paths)
  defp path_under?(base, path)
end
```

This distributed approach has drawbacks:

1. **Inconsistent validation** - Each executor implements its own checks
2. **Duplicated logic** - Path validation repeated across modules
3. **Harder to audit** - Security code spread throughout codebase
4. **Testing gaps** - Security functions tested indirectly

## Decision

We will implement a centralized `Conjure.Security` module that consolidates all security-related functionality:

```elixir
defmodule Conjure.Security do
  @moduledoc """
  Centralized security utilities for Conjure.

  This module provides path validation, input sanitization, and
  security policy enforcement used by executors and other components.
  """

  # ============================================================
  # Path Validation
  # ============================================================

  @doc """
  Validates that a path is within allowed boundaries.

  Returns `{:ok, normalized_path}` if the path is allowed,
  or `{:error, :path_not_allowed}` if it's outside allowed paths.

  ## Examples

      iex> validate_path("/workspace/file.txt", ["/workspace"])
      {:ok, "/workspace/file.txt"}

      iex> validate_path("/etc/passwd", ["/workspace"])
      {:error, :path_not_allowed}

      iex> validate_path("../../../etc/passwd", ["/workspace"])
      {:error, :path_not_allowed}
  """
  @spec validate_path(Path.t(), [Path.t()]) :: {:ok, Path.t()} | {:error, :path_not_allowed}
  def validate_path(path, allowed_paths)

  @doc """
  Checks if a path is under a base directory.
  Handles path traversal attempts (../).
  """
  @spec path_under?(Path.t(), Path.t()) :: boolean()
  def path_under?(base, path)

  @doc """
  Normalizes and expands a path, resolving symlinks.
  """
  @spec normalize_path(Path.t()) :: Path.t()
  def normalize_path(path)

  # ============================================================
  # Input Sanitization
  # ============================================================

  @doc """
  Escapes a string for safe use in shell commands.
  Uses single-quote wrapping with proper escape sequences.
  """
  @spec escape_shell(String.t()) :: String.t()
  def escape_shell(input)

  @doc """
  Escapes a string for safe use in Python string literals.
  Handles unicode and special characters.
  """
  @spec escape_python(String.t()) :: String.t()
  def escape_python(input)

  @doc """
  Validates that a command doesn't contain dangerous patterns.
  Returns {:ok, command} or {:error, :dangerous_command}.
  """
  @spec validate_command(String.t(), keyword()) :: {:ok, String.t()} | {:error, atom()}
  def validate_command(command, opts \\ [])

  # ============================================================
  # Policy Enforcement
  # ============================================================

  @doc """
  Checks if an operation is allowed by the execution context.
  """
  @spec allowed?(atom(), ExecutionContext.t()) :: boolean()
  def allowed?(operation, context)

  @doc """
  Returns the effective allowed paths for a context,
  combining global config with context-specific paths.
  """
  @spec effective_allowed_paths(ExecutionContext.t()) :: [Path.t()]
  def effective_allowed_paths(context)

  # ============================================================
  # Audit Logging
  # ============================================================

  @doc """
  Logs a security-relevant event via telemetry.
  """
  @spec audit(atom(), map()) :: :ok
  def audit(event, metadata)
end
```

### Migration Plan

1. Create `Conjure.Security` module with all functions
2. Update `Conjure.Executor.Local` to delegate to Security module
3. Update `Conjure.Executor.Docker` to delegate to Security module
4. Add comprehensive tests for Security module
5. Deprecate inline security functions (if any exposed)

### Usage in Executors

```elixir
defmodule Conjure.Executor.Local do
  alias Conjure.Security

  def view(path, context, _opts) do
    with {:ok, safe_path} <- Security.validate_path(path, context.allowed_paths),
         {:ok, normalized} <- Security.normalize_path(safe_path) do
      # Proceed with file read
    end
  end

  def bash(command, context) do
    with {:ok, _} <- Security.validate_command(command),
         :ok <- Security.audit(:bash_execution, %{command: command}) do
      # Proceed with execution
    end
  end
end
```

## Consequences

### Positive

- **Single source of truth** for security logic
- **Easier auditing** - all security code in one place
- **Consistent behavior** across executors
- **Better testing** - security functions tested in isolation
- **Reusable** - custom executors can use the same utilities

### Negative

- Migration effort to consolidate existing code
- Additional module to maintain
- Potential performance overhead from extra function calls (minimal)

### Neutral

- Executors remain responsible for calling security functions
- Doesn't change the fundamental security model
- Custom executors can bypass if they choose (their responsibility)

## Alternatives Considered

### Middleware/Pipeline Approach

Wrap all executor calls in security middleware. Rejected because:

- Adds complexity to the executor behaviour
- Less flexibility for custom security policies
- Harder to understand call flow

### Leave Distributed

Keep security logic in individual executors. Rejected because:

- Current state leads to inconsistencies
- Harder to audit and maintain
- Duplicated code

### Security Behaviour

Define a `Conjure.Security` behaviour that executors must implement. Rejected because:

- Over-engineering for the use case
- Security policy is orthogonal to execution strategy
- Module with functions is simpler

## References

- [ADR-0009: Local executor without sandboxing](0009-local-executor-no-sandbox.md)
- [ADR-0010: Docker as production executor](0010-docker-production-executor.md)
- [OWASP Path Traversal](https://owasp.org/www-community/attacks/Path_Traversal)
- [Elixir Security Best Practices](https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/)