Skip to content

[bug] policy inheritance: child without unmanaged_files block silently downgrades parent's action: deny #1198

@danielmeppiel

Description

@danielmeppiel

Summary

When a child policy declares extends: org but does NOT redeclare an unmanaged_files: block, the merged policy's unmanaged_files.action is silently downgraded to the model's default (ignore) instead of inheriting the parent's deny.

The _escalate(_UNMANAGED_ACTION_LEVELS, parent.action, child.action) call at src/apm_cli/policy/inheritance.py:198 correctly takes the max, but the child.unmanaged_files for a child that omits the block is not "no opinion" — it's UnmanagedFilesPolicy(action='ignore', directories=()). So max('deny', 'ignore') == 'deny' should hold but the resulting merged check reports as disabled.

Repro

org/.github/apm-policy.yml:

name: org
version: "1.0.0"
enforcement: block
unmanaged_files:
  action: deny
  directories:
    - .github/instructions
    - .github/agents
    - .github/hooks

repo/apm-policy.yml:

name: child
version: "1.0.0"
extends: org
enforcement: block
dependencies:
  deny:
    - "**/some-pattern"
# (no unmanaged_files block)

Add an unmanaged file (e.g. .github/hooks/sneaky.hook.md) and run:

apm audit --ci --policy ./apm-policy.yml

Expected: unmanaged-files check fails with action deny inherited from org.

Actual: [+] unmanaged-files | Unmanaged files check disabled (action: ignore) and the audit passes.

Workaround

Redeclare the unmanaged_files block in every override that should preserve the parent's posture:

extends: org
unmanaged_files:
  action: deny
  directories:
    - .github/agents
    - .github/instructions
    - .github/hooks

This loses the value proposition of extends: (single source of truth at the org root) — the override has to mirror the parent's directory list verbatim, which is exactly what the inheritance feature is supposed to avoid.

Suggested fix

Two routes:

  1. Make the model field on UnmanagedFilesPolicy distinguish "default" from "explicitly set". A child that did NOT declare unmanaged_files: should be treated as transparent in _merge_unmanaged_files, returning parent unchanged. (Mirrors how _intersect_allow treats None as "no opinion".)
  2. Or: at parse time, propagate parent values into any child block fields the child did not explicitly set, before invoking the merge.

The first is cleaner because it matches the rest of the inheritance.py contract.

Affected versions

Reproduced with apm v0.x (current main). Same logic at src/apm_cli/policy/inheritance.py:194-200.

Impact

Org-wide governance posture silently weakens whenever a team adds a repo override for an unrelated policy area (e.g. extra dependency deny patterns). Hard to detect — apm audit --policy org shows the right floor, but apm audit --policy ./apm-policy.yml (which the consumer typically runs in CI for layered enforcement) silently ignores the floor for unmanaged_files.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions