Skip to content

Composable, shareable moat.yaml configs via extends (inheritance) #399

Description

@dpup

Summary

Make moat.yaml configs composable and shareable: let a config inherit from one or more base configs (extends), and let those bases be resolved from a shared location (local path, git ref, or URL) so a team can publish a vetted base policy once and reuse it across many projects.

Today every moat.yaml is standalone. Teams running many agents end up copy-pasting the same grants, network policy, and dependency settings into every repo — which drifts and is hard to keep consistent. This adds inheritance and sharing so there's a single source of truth.

Scope note: this issue is config composition + sharing only. Signing / attestation of shared configs is explicitly out of scope here and can be layered on later.

Motivation

  • Eliminate duplication. A standard "python-agent" or "locked-down" policy (grants, network allowlist, sensitive-path denies, dependencies) gets defined once and inherited, not re-pasted per repo.
  • Consistency + central updates. Tighten the team base policy in one place and every project that extends it picks up the change.
  • Lower the barrier to a good default. New projects start from a vetted base instead of a blank moat.yaml.

Proposed mechanism

Add an extends key that lists one or more base configs, resolved and deep-merged under the local config:

# moat.yaml
extends:
  - ./base/locked-down.yaml          # local path
  - github:myorg/moat-configs//python-agent.yaml@v1   # shared, pinned to a ref

grants:
  - github                            # merges with / overrides the bases
network:
  allow_hosts:
    - api.internal.example.com        # appended to base allow_hosts

Resolution sources (first cut)

  • Local/relative path./base.yaml, ../shared/base.yaml
  • Git refgithub:org/repo//path/to/config.yaml@ref (pin to a tag/sha; document that an unpinned ref is resolved at run time and is not reproducible)
  • (Optional, later) plain HTTPS URL

Merge semantics (needs to be precise and documented)

  • Bases are applied in order; the local file is applied last and wins on conflict.
  • Scalars: later value overrides earlier.
  • Maps: deep-merged key by key.
  • Lists: define one explicit default and document it — proposal: append/union with de-dup for additive things (grants, network.allow_hosts), since that's the common intent. Provide an escape hatch to fully replace a list rather than append (e.g. a !replace tag or a lists: replace directive) so a child can intentionally drop an inherited entry.
  • Removal: support explicitly removing an inherited entry (e.g. a network.deny_hosts-style negation or a documented "remove this key" sentinel), so a child can opt out of something a base granted.

These rules are the part most likely to bite — see Codebase Invariants on companion-case testing. Every merge rule needs a test for both directions (value present in base only / child only / both; list append vs. replace; removal).

Configuration / CLI sketch

moat config resolve            # print the fully-merged, flattened config for this dir
moat config resolve --explain  # show which base each field came from

A resolve --explain view matters: once configs inherit, "why is this host allowed?" needs a traceable answer.

Scope for a first cut

  • extends key accepting a list of bases
  • Local/relative path resolution
  • Git-ref resolution with pinning (tag/sha)
  • Deep-merge with documented, tested precedence (scalars override, maps deep-merge, lists append-union)
  • List-replace escape hatch + inherited-entry removal
  • Cycle detection (A extends B extends A) with a clear error
  • moat config resolve / --explain
  • Validation runs against the merged result, with errors pointing back to the source file/base
  • Docs: extends, resolution sources, merge semantics, reproducibility caveats

Edge cases / guardrails

  • Cycles — detect and error clearly rather than looping.
  • Missing/unreachable base — fail with an actionable message (which base, where it was referenced); don't silently skip it.
  • Reproducibility — an unpinned git ref or URL means the effective config can change out from under a run. Document this; consider warning when a base is unpinned.
  • Caching — remote bases should be cached locally; define cache location and a refresh/clear path.
  • Empty / malformed base — handle empty strings, trailing-separator names, and malformed YAML the same way the standalone loader does.

Open questions

  • Single extends chain vs. a list of multiple bases — start with a list (more flexible) or a single base (simpler merge story)?
  • Default list-merge behavior: append-union vs. replace — which is least surprising as the default? (Proposal above: append-union, with explicit replace opt-out.)
  • Where do remote bases cache, and how are they pinned/locked (a moat.lock-style record of resolved shas for reproducibility)?

Out of scope (tracked separately)

  • Signing / attestation of shared configs — deliberately deferred. This issue gets sharing + inheritance working first; provenance/verification of bases is a later layer.
  • A hosted registry — extends from a git repo or path covers the sharing need without standing up registry infrastructure.

Metadata

Metadata

Assignees

No one assigned

    Fields

    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions