feat(policy): declarable integrity keys (require_hashes, fail_on_drift)#1794
Conversation
Add two additive, optional, default-off policy keys under the existing security: namespace, both backed by enforcement that exists today: - security.integrity.require_hashes: require lockfile content hashes for all installs. A missing/empty hash on a non-local lockfile entry fails the install closed (no second hashing pass; asserts hash-presence on the freshly-built lockfile). Local deps are exempt. - security.audit.fail_on_drift: make a bare `apm audit` exit non-zero when workspace content drifts from the lockfile. Sits alongside the existing security.audit.on_install. Only changes the exit code; the drift scan is unchanged and `apm audit --ci` already gates on drift. Both keys carry through policy inheritance with logical-OR semantics (tighten-not-relax; a silent child preserves a parent that enabled them) and keep the structural merge coverage guard passing. No new top-level namespace; no signatures/provenance/sbom keys (deferred to #1777) -- a key that validates but enforces nothing is a false-assurance liability. Tests (TDD, red first): golden parse-equivalence over every policy fixture (proves old policies parse identically), None-transparency on merge for both keys, and fail-closed enforcement (missing hash fails under require_hashes; drift exits non-zero under fail_on_drift while default-off preserves current behavior). New on/off fixtures per key. Docs: policy-schema reference + apm-usage governance skill. CHANGELOG Unreleased Added entry. Part of #1774 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
⚠️ Not ready to approve
The current implementation can leak credentials in error output and has edge cases where policy-enabled drift/hash enforcement can silently fail open.
Pull request overview
This PR makes two existing integrity behaviors declarable in apm-policy.yml under the existing security: namespace: (1) requiring lockfile content hashes for non-local dependencies (security.integrity.require_hashes) and (2) making apm audit exit non-zero on drift (security.audit.fail_on_drift), with tighten-not-relax inheritance semantics and accompanying tests/docs.
Changes:
- Extend policy schema + parsing + inheritance merge to include
security.integrity.require_hashesandsecurity.audit.fail_on_drift(both default-off, tighten-only). - Enforce
require_hashesin the install pipeline by reading the freshly-written lockfile and failing closed when hashes are missing. - Make
apm auditoptionally gate on drift via policy, plus add golden-fixture parse-equivalence + targeted unit tests and docs/changelog updates.
File summaries
| File | Description |
|---|---|
src/apm_cli/policy/schema.py |
Adds AuditPolicy.fail_on_drift and new IntegrityPolicy under SecurityPolicy. |
src/apm_cli/policy/parser.py |
Validates/parses the two new policy keys. |
src/apm_cli/policy/inheritance.py |
OR-merges the new boolean keys to enforce tighten-not-relax inheritance. |
src/apm_cli/install/integrity.py |
New integrity enforcement helpers (unhashed_dependencies, enforce_require_hashes). |
src/apm_cli/install/pipeline.py |
Hooks require_hashes enforcement after lockfile generation. |
src/apm_cli/commands/audit.py |
Adds _resolve_fail_on_drift() and escalates apm audit exit code on drift when enabled. |
tests/unit/install/test_require_hashes.py |
Unit tests for fail-closed hash requirement logic. |
tests/unit/test_audit_fail_on_drift.py |
Unit tests for apm audit exit code escalation when drift is detected. |
tests/unit/policy/test_integrity_keys.py |
Schema defaults, parsing, validation, and inheritance-tightening tests for the new keys. |
tests/unit/policy/test_policy_golden.py |
Golden snapshot-based parse-equivalence test over policy fixtures. |
tests/fixtures/policy/golden/parsed-policies.json |
Updated golden snapshot to include the new default-off fields. |
tests/fixtures/policy/apm-policy-*.yml |
Adds fixtures covering on/off/combined scenarios for the new keys. |
docs/src/content/docs/reference/policy-schema.md |
Documents the two new policy keys and their inheritance behavior. |
packages/apm-guide/.apm/skills/apm-usage/governance.md |
Updates governance guidance with integrity/drift enforcement keys. |
CHANGELOG.md |
Adds an Unreleased entry describing the new policy keys. |
Copilot's findings
- Files reviewed: 19/19 changed files
- Comments generated: 4
Note
Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.
| names = ", ".join(sorted(d.repo_url for d in missing)) | ||
| raise RuntimeError( | ||
| "security.integrity.require_hashes is enabled but these locked " | ||
| f"dependencies have no content hash (fail-closed): {names}. " | ||
| "Re-run the install so the lockfile records a hash for every entry." | ||
| ) |
| # Bare `apm audit` is advisory for drift by default: drift findings are | ||
| # rendered (text/json/sarif) but DO NOT escalate the exit code. When | ||
| # `security.audit.fail_on_drift` is enabled, actual drift escalates a clean | ||
| # run to exit 1 (matching the `apm audit --ci` gate). Policy is discovered | ||
| # only when drift was detected, so the no-drift common case is unchanged. | ||
| _ = drift_failed # retained for symmetry; --ci gate lives in _audit_ci_gate. | ||
| if drift_findings and exit_code == 0 and _resolve_fail_on_drift(project_root): | ||
| exit_code = 1 |
| lockfile = LockFile.read(get_lockfile_path(apm_dir)) | ||
| if lockfile is None: | ||
| return |
| golden = json.loads(GOLDEN_PATH.read_text(encoding="utf-8")) | ||
|
|
||
| # Every previously-snapshotted fixture must parse identically. | ||
| for name, canonical in golden.items(): | ||
| with self.subTest(fixture=name): | ||
| self.assertIn(name, snapshot, f"fixture {name} disappeared") |
Addresses four Copilot inline findings on the two new default-off policy keys, all within the PR's stated scope (require_hashes / fail_on_drift parsing, enforcement, tests): - install/integrity.py: redact inline user:token@host credentials in the fail-closed error before naming offenders (reuses the canonical _redact_url_credentials), so a token can no longer leak into terminal or CI logs. Addresses Copilot inline on src/apm_cli/install/integrity.py. - commands/audit.py: escalate bare `apm audit` on any drift-check FAILURE (drift_failed) rather than only when findings exist, so a drift check that could not run (passed=False, no findings) still gates when fail_on_drift is on -- matching the `apm audit --ci` signal. An advisory cache-miss SKIP stays passed=True and does not gate. Addresses Copilot inline on src/apm_cli/commands/audit.py. - install/pipeline.py: fail closed (PolicyViolationError) when require_hashes is enabled but the freshly-written lockfile is missing or unreadable, instead of silently returning and letting install pass. Addresses Copilot inline on src/apm_cli/install/pipeline.py. - tests/unit/policy/test_policy_golden.py: assert the fixture set and golden snapshot set match exactly, so a new *.yml fixture added without regenerating the golden fails instead of being silently skipped. Addresses Copilot inline on tests/unit/policy/test_policy_golden.py. Each new regression-trap test passed the mutation-break gate (guard removed -> test fails -> guard restored). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python architect | 0 | 1 | 1 | Clean additive architecture; one coupling smell (private cross-module import) worth relocating to utils. |
| Cli logging expert | 0 | 1 | 1 | Output UX solid; one recommended: silent exit-code escalation in audit needs a stderr hint for CI users. |
| Devx ux expert | 0 | 3 | 2 | Default-off keys nest well under security:; error wording needs one-action fix hint; fail_on_drift/--ci overlap should be doc-clarified. |
| Supply chain security expert | 0 | 0 | 3 | Solid fail-closed design; local exemption safe; fail-open on drift policy discovery defensible for an opt-in escalation key. |
| Oss growth hacker | 0 | 0 | 2 | Strong story angle for supply-chain governance adoption; CHANGELOG and docs self-serve ready. |
| Auth expert | 0 | 0 | 1 | Credential redaction in the fail-closed path is correct and tested; no auth/token changes; no leak vectors. |
| Doc writer | 0 | 2 | 1 | Docs accurate on core behavior; two undocumented fail-closed edges plus a minor cross-link gap. |
| Test coverage expert | 0 | 2 | 0 | 37 unit tests pass; critical-surface gates tested at unit tier; integration-tier coverage is the top additive gap. |
| Performance expert | 0 | 0 | 3 | No install wall-time regression; default-off path is ~0; enabled path re-reads lockfile (minor reuse opportunity). |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Test coverage expert] Add integration-with-fixtures test for require_hashes fail-closed (real lockfile, real exit code) -- outcome:missing on a secure_by_default surface; locks the user promise against future refactors that move the call site.
- [Test coverage expert] Add integration-with-fixtures test for fail_on_drift exit-code escalation (real drifted workspace, real exit code) -- outcome:missing on a governed_by_policy surface; same rationale: thin gate but no fixture guards it today.
- [Cli logging expert] Emit a stderr hint when fail_on_drift escalates exit 0 to 1 (name the policy key and how to disable) -- CI users see advisory output but the job fails with invisible cause; one line closes the debuggability gap.
- [Doc writer] Document the two fail-closed edges in policy-schema.md: unreadable lockfile + drift-check-could-not-run escalation -- code enforces two triggers not yet described in docs; security-relevant edges should be self-serve discoverable.
- [Python architect] Relocate
_redact_url_credentialsto a shared utils module and make it public -- three panelists flagged independently; prevents coupling compounding at the next call site addition.
Architecture
classDiagram
direction LR
class ApmPolicy {
<<ValueObject>>
+security SecurityPolicy
}
class SecurityPolicy {
<<ValueObject>>
+audit AuditPolicy
+integrity IntegrityPolicy
}
class AuditPolicy {
<<ValueObject>>
+on_install str or None
+external tuple or None
+scanners tuple or None
+fail_on_drift bool
}
class IntegrityPolicy {
<<ValueObject>>
+require_hashes bool
}
class enforce_require_hashes {
<<Pure>>
+enforce_require_hashes(deps, enabled) None
+unhashed_dependencies(deps) list
}
class PolicyViolationError {
<<Exception>>
}
class _enforce_require_hashes_pipeline {
<<IOBoundary>>
+_enforce_require_hashes(ctx) None
}
class _resolve_fail_on_drift_audit {
<<IOBoundary>>
+_resolve_fail_on_drift(project_root) bool
}
ApmPolicy *-- SecurityPolicy
SecurityPolicy *-- AuditPolicy
SecurityPolicy *-- IntegrityPolicy
_enforce_require_hashes_pipeline ..> IntegrityPolicy : reads policy
_enforce_require_hashes_pipeline ..> enforce_require_hashes : delegates
_enforce_require_hashes_pipeline ..> PolicyViolationError : raises
_resolve_fail_on_drift_audit ..> AuditPolicy : reads policy
enforce_require_hashes ..> IntegrityPolicy : parameterized by enabled
note for SecurityPolicy "Composite of frozen siblings;\neach sub-policy merges independently"
note for enforce_require_hashes "Pure decision fn;\nno I/O, no side effects"
class IntegrityPolicy:::touched
class AuditPolicy:::touched
class enforce_require_hashes:::touched
class _enforce_require_hashes_pipeline:::touched
class _resolve_fail_on_drift_audit:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["apm install CLI"] --> B["run_install_pipeline()\nsrc/apm_cli/install/pipeline.py"]
B --> C["LockfileBuilder.build_and_save()\n[FS] writes apm.lock.yaml"]
C --> D{"_enforce_require_hashes(ctx)\npolicy disabled or key off?"}
D -->|"no-op"| E["continue install"]
D -->|"enabled"| F["[FS] LockFile.read(lockfile_path)"]
F --> G{"lockfile is None?"}
G -->|"yes"| H["raise PolicyViolationError\nFAIL CLOSED"]
G -->|"no"| I["enforce_require_hashes(deps, enabled=True)\nsrc/apm_cli/install/integrity.py"]
I --> J{"unhashed_dependencies empty?"}
J -->|"yes"| E
J -->|"no"| K["raise RuntimeError -> PolicyViolationError\nFAIL CLOSED"]
L["apm audit CLI"] --> M["_audit_content_scan()\nsrc/apm_cli/commands/audit.py"]
M --> N{"drift_failed and exit_code == 0?"}
N -->|"no"| O["return exit_code as-is"]
N -->|"yes"| P["_resolve_fail_on_drift(project_root)"]
P --> Q{"APM_POLICY_DISABLE or discovery error?"}
Q -->|"yes"| R["return False -- FAIL OPEN"]
Q -->|"no"| S{"policy.security.audit.fail_on_drift?"}
S -->|"False"| O
S -->|"True"| T["exit_code = 1"]
Recommendation
Zero blocking findings across 9 specialists. Unit coverage locks both user promises on this commit (37 tests, 1.18s, all green). The remaining recommended items are polish and additive coverage -- none alter behavior, none risk regression, and all are cheaper as a focused follow-up PR than as merge-blocking scope creep on a feature that is already default-off and fail-closed-correct. The maintainer should address the out-of-band Spec Conformance gate as a governance decision (spec citation or waiver) orthogonal to code quality. Ship now; open a follow-up issue tracking the 5 items above.
Full per-persona findings
Python architect
- [recommended]
_redact_url_credentialsis a private function imported cross-module -- relocate to shared utils atsrc/apm_cli/install/integrity.py:52
install/integrity.py imports_redact_url_credentialsfrom install/mcp/registry.py. A private helper reused across module boundaries couples integrity enforcement to the MCP registry; the function is general-purpose URL sanitization with no MCP logic. Extracting now (2 call sites) prevents compounding coupling.
Suggested: Move_redact_url_credentialstosrc/apm_cli/utils/url.py, make it public, and import from there in registry.py and integrity.py. - [nit]
enforce_require_hashesraises RuntimeError rather than a domain exception atsrc/apm_cli/install/integrity.py:55
Generic RuntimeError wrapped into PolicyViolationError by the pipeline. Fine today (single caller); defineHashIntegrityErrorif a second caller appears.
Cli logging expert
- [recommended] Silent exit-code escalation needs a stderr hint at
src/apm_cli/commands/audit.py:979
When fail_on_drift escalates exit 0 to 1 there is no stderr message explaining why. A CI user sees advisory-looking findings but the job fails with an invisible cause. Emit a parallel stderr line after setting exit_code=1, e.g. an info line namingsecurity.audit.fail_on_drift. - [nit] Error message: prefer the exact command at
src/apm_cli/install/integrity.py:56
"Re-run the install..." reads better as "Re-runapm installto regenerate the lockfile with hashes."
Devx ux expert
- [recommended] fail_on_drift error should name the policy key that triggered it at
src/apm_cli/commands/audit.py:979
The user sees exit 1 fromapm auditwith no indication a policy made the advisory check a hard gate. Add a one-line diagnostic namingsecurity.audit.fail_on_driftand where to disable it. - [recommended] require_hashes error should give the exact remediation command at
src/apm_cli/install/integrity.py:54
Be explicit about the regenerate command; the intermediate RuntimeError also leaks framework-smell if surfaced raw -- consider raising PolicyViolationError directly or a domain error. - [recommended] Document fail_on_drift vs --ci relationship in
apm audit --help
Two ways to gate on drift (flag vs policy); --help should cross-mention the policy key for discoverability. - [nit] integrity vs audit nesting asymmetry is fine; a schema comment helps at
docs/src/content/docs/reference/policy-schema.md
require_hashes under security.integrity, fail_on_drift under security.audit -- semantically correct and already noted in docs. - [nit] Consider logging which policy file activated require_hashes on failure at
src/apm_cli/install/pipeline.py:345
Naming the ancestor policy file/URL in the error would speed debugging in inheritance chains.
Supply chain security expert
- [nit]
_redact_url_credentialsfallback returns the original URL on parse error -- theoretical leak on malformed URLs atsrc/apm_cli/install/mcp/registry.py:58
except (ValueError, TypeError): return urlcould emit user:token@ if urlparse fails but the string still has credentials. Extremely unlikely; defense-in-depth would regex-strip://[^@]+@before the fallback return.
Suggested: regex fallback strip of://[^@]+@before returning the raw URL in the except branch.
Proof (passed):tests/unit/install/test_require_hashes.py::test_enabled_redacts_credentials_in_message-- proves: standard user:token@host redaction works; the fallback path is reachable only with degenerate URLs. - [nit]
_resolve_fail_on_driftfails open on discovery exception -- defensible but worth a log line atsrc/apm_cli/commands/audit.py:799
A bareexcept Exception: return Falseswallows policy-discovery failures, reverting to advisory. Correct for an opt-in default-off escalation key, but an operator who opted in gets zero signal the gate was bypassed; a debug/warning log adds observability without changing behavior.
Suggested: logger.debug/warning in the except branch. - [nit] No direct test of the
_resolve_fail_on_driftexception-handling branch attests/unit/test_audit_fail_on_drift.py
Tests mock the return value rather than exercising the swallow logic. A test patching discover_policy_with_chain to raise and asserting False would lock the fail-open contract.
Suggested: add a test that patches discover_policy_with_chain to raise and asserts False.
Oss growth hacker
- [nit] CHANGELOG entry is dense for a release note at
CHANGELOG.md:10
Two features in one sentence; for the eventual release note consider splitting with a why-you-care framing. Fine for the raw CHANGELOG. - [nit] Add a one-liner CI snippet to the new section at
docs/src/content/docs/reference/policy-schema.md:200
Show the CI payoff: a singleapm auditline noting it exits non-zero on drift when fail_on_drift is enabled closes the loop for a security engineer.
Auth expert
- [nit] Re-export a canonical redactor rather than importing a private helper cross-package at
src/apm_cli/install/integrity.py:54
_redact_url_credentialsis private in install/mcp/registry.py; importing across sub-packages couples unrelated modules. A thin re-export in utils/ improves discoverability. Functionally correct today -- pure hygiene.
Doc writer
- [recommended] require_hashes docs omit the unreadable/missing-lockfile fail-closed case at
docs/src/content/docs/reference/policy-schema.md:167
pipeline._enforce_require_hashes raises PolicyViolationError when the freshly-written lockfile cannot be read -- a second fail-closed trigger distinct from missing-hash, and the more security-relevant one. Docs describe only the missing-hash trigger.
Suggested: append "An unreadable or missing lockfile also fails the install closed -- never a silent pass." - [recommended] fail_on_drift gating surface is narrower in docs than in code at
docs/src/content/docs/reference/policy-schema.md:166
audit.py escalates on any drift-check failure (covers detected drift and a drift check that could not run), while an advisory cache-miss skip does not gate. Docs frame the trigger only as "when drift is detected."
Suggested: note that any drift-check failure escalates the exit code (including a check that could not run), but an advisory cache-miss skip does not gate. - [nit] governance.md duplicates the reference field tables without a cross-link at
packages/apm-guide/.apm/skills/apm-usage/governance.md:93
Duplication is defensible but a cross-reference to policy-schema.md keeps the two from drifting.
Suggested: add a pointer to reference/policy-schema.md as authoritative.
Test coverage expert
- [recommended] No integration test exercises require_hashes fail-closed via a real lockfile
The install pipeline floor is integration-with-fixtures; unit tests mock LockFile.read. Production logic is a thin 15-line gate and the mocked seam is already validated by the existing install integration suite. Risk is low but non-zero: a future refactor could move the call site unnoticed. Zero matches for "require_hashes" in tests/integration/.
Suggested: add a parametrized integration test invokingapm installwith an unhashed entry + require_hashes:true asserting non-zero exit.
Proof (passed):tests/unit/install/test_require_hashes.py::TestPipelineEnforceRequireHashes::test_unreadable_lockfile_fails_closed_when_enabled-- proves: install fails closed when require_hashes is on and the lockfile is unreadable [Secure by default]
self.assertRaises(PolicyViolationError)
Proof (missing):tests/integration/test_install_require_hashes.py::test_require_hashes_blocks_install_on_unhashed_entry-- proves: a user with require_hashes enabled cannot install a package whose lockfile entry has no content_hash [Secure by default,Governed by policy]
assert result.exit_code != 0 and 'require_hashes' in result.stderr - [recommended] No integration test exercises fail_on_drift exit-code escalation via a real audit invocation
The audit exit-code floor is integration-with-fixtures; unit tests mock _resolve_fail_on_drift and _check_drift. Existing integration tests cover scanner exit codes but none activate fail_on_drift. The gate is 3 lines and the drift pipeline underneath is integration-tested. Risk is low. Zero matches for "fail_on_drift" in tests/integration/.
Suggested: add a test creating a drifted fixture workspace + fail_on_drift:true asserting SystemExit != 0.
Proof (passed):tests/unit/test_audit_fail_on_drift.py::test_drift_with_fail_on_drift_exits_nonzero-- proves: apm audit exits non-zero on drift when fail_on_drift is enabled [Secure by default,Governed by policy]
assert _run(tmp_path, fail_on_drift=True) != 0
Proof (missing):tests/integration/test_audit_fail_on_drift.py::test_fail_on_drift_policy_exits_nonzero_on_real_drift-- proves: a user running bare apm audit with fail_on_drift sees non-zero exit on workspace drift [Secure by default,Governed by policy]
assert result.exit_code != 0
Performance expert
- [nit] Re-reading the lockfile from disk when the builder already holds entries in memory at
src/apm_cli/install/pipeline.py:_enforce_require_hashes
The gate calls LockFile.read on the just-written file (~50-200us). The builder already has the entries in memory; passing them avoids a redundant read. Reading the persisted file also gives a verify-what-was-written guarantee -- defensible. Nit only. - [nit] Default-off fast path is correctly negligible at
src/apm_cli/install/pipeline.py:_enforce_require_hashes
Disabled key: at most 3 getattr + a bool check before return. ~0 cost. - [nit] Policy discovery gated behind drift_failed -- the clean path is untouched at
src/apm_cli/commands/audit.py:967
_resolve_fail_on_drift only runs when drift_failed and exit_code==0; the common no-drift path never invokes policy discovery. Correct.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Author OpenAPM v0.1 requirement req-pl-015 (Section 6.3.5, governance
MUST) codifying the unmanaged-artifact surfacing behavior added in this
PR, resolving the Mode B spec-conformance gate honestly (spec is the
contract) rather than waiving it.
req-pl-015 governs reporting COMPLETENESS only: a governance
implementation evaluating policy over a populated primitive target tree
MUST surface every file under a managed primitive target directory that
is neither recorded in apm.lock.yaml nor matched by a configured
unmanaged_files.exclude glob, each carrying its unmanaged reason, a
dependency/MCP deny-conflict note where applicable, and a determinable
inferred primitive type; an excluded path MUST NOT be surfaced.
Enforcement stays governed by unmanaged_files.action -- this is not an
enforcement claim.
Citation ritual (all sites edited for orphan_check + linter-check-6):
- Section 6.3.5 anchor + normative prose.
- Section 6.4 merge table: unmanaged_files.exclude row (union, dedup,
parent order preserved -- matches inheritance._union).
- Section 1.3 + Appendix C count 87 -> 88 (83 MUST); Appendix C row;
Section 6.8 governance trailer; Appendix D erratum row.
- Manifest entry after req-pl-012; regenerated CONFORMANCE.{md,json}.
- New @pytest.mark.req("req-pl-015") behavioral test binding the spec
MUST to _check_unmanaged_files (reason, type, deny-conflict, exclude).
Cross-PR note: sibling spec-citation PR #1794 also edits the shared
count sites; whichever merges second needs a trivial count reconcile.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Author the OpenAPM v0.1 spec citation for the two declarable integrity
policy keys this PR introduces, resolving the Mode B (silent-extension)
gate by adding the contract rather than waiving it.
Adds two governance MUST requirements under a new Section 6.8
"Integrity controls":
- req-pl-013: security.integrity.require_hashes -- a conforming
governance implementation MUST fail the install fail-closed when a
resolved non-local dependency lacks a recorded integrity hash in
apm.lock.yaml, or when the lockfile is absent/unreadable. Wording
matches the implementation: enforcement aborts the install operation
(it does not assert pre-integration ordering), and local deps are
exempt (anchored by deployed-file hashes).
- req-pl-014: security.audit.fail_on_drift -- a conforming governance
implementation MUST exit the audit non-zero on detected drift or a
drift check that cannot complete; an advisory skip (cache miss) does
not, by itself, alter the exit status.
Also adds the non-normative Section 6.3.6 security field reference, two
merge-table rows (logical OR / tighten-not-relax, matching
inheritance.py), two Appendix C rows, two manifest entries, an Appendix
D revision row, and a spec-conformance fixture plus two
@pytest.mark.req tests binding the parsed booleans to the spec text.
Renumbers the governance conformance trailer 6.8 -> 6.9 and updates the
one inbound link. Statement count 87 -> 89 (84 MUST, 5 SHOULD);
CONFORMANCE.{json,md} regenerated.
Cross-PR note: this edits shared count sites (Section 1.3, Appendix C
trailer, Appendix D); a sibling spec-citation PR also edits them, so
whichever lands second reconciles the cumulative total and unions the
added rows.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fold the convergent apm-spec-guardian finding (pkgmgr + oci panels): the phrase "recorded integrity hash" in req-pl-013 and the 6.3.6 field reference did not name which lockfile field anchors the fail-closed check. Bind it to the concrete `content_hash` field that install/integrity.py actually inspects for every non-local entry, so a conformant implementation cannot diverge on which field satisfies the predicate. Local deps remain exempt. Prose-only; no anchor/count/ manifest change (orphan_check still 89-aligned). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Spec Guardian:
|
| Panel | Verdict | Shocked | New B | New R | New N |
|---|---|---|---|---|---|
| Spec Oci Editor | ship | 8/10 | 0 | 1 | 1 |
| Spec Swagger Editor | ship | 8/10 | 0 | 1 | 2 |
| Spec Pkgmgr Editor | ship | 7/10 | 0 | 1 | 1 |
| Spec Tag Architect | ship | 8/10 | 0 | 2 | 1 |
B = new blocking findings, R = new recommended, N = new nits.
Counts are signal strength, not gates. The maintainer ships.
Convergent themes (flagged by 2+ panels)
- ct-1 -- "recorded integrity hash" was not bound to a concrete lockfile field (supporting: pkg-001, oci-r1-001) -- FOLDED in
62258eb7.
Fold now (1 item)
- [ct-1 / fidelity] 6.8 (req-pl-013) + 6.3.6 -- Bind the phrase "recorded integrity hash" to the
content_hashlockfile field thatsrc/apm_cli/install/integrity.pyinspects for every non-local entry, so two conformant implementations cannot diverge on which field satisfies the fail-closed predicate. (The panels proposed a per-source-type table; that was narrowed to the singlecontent_hashfield because the implementation checks exactly one field uniformly -- a per-source-type table would over-specify beyond the code.)
Success criterion:req-pl-013 and 6.3.6 name content_hash; orphan_check stays 89-aligned; spec pytest green
Defer to v0.1.1
- [sw-rec-r1-1 / testability] 6.8 (req-pl-014) -- add an informative note that conformance suites must exercise both the enabled (non-zero on drift) and default (report-only) code paths.
- [tag-r02 / layering] 6.3.6 -- the non-normative field reference restates the logical-OR merge rule that already lives in the req-pl-006 merge table; collapse to a pure cross-reference for single-source-of-truth.
- [pkg-n01 / precision] 6.3.6 -- prefer the exact field name
local_deployed_file_hashesover the generic "deployed-file hashes". - [oci-n1-001 / traceability] 6.8 + 6.3.6 -- cross-reference the lockfile trust-anchor requirement (req-lk-012) from the local-dep exemption clause.
- [sw-nit-r1-1 + tag-n02 / housekeeping] Appendix D 0.1.3 -- after the sibling spec-citation PR (feat(audit): surface unmanaged artifacts with reason, type, and deny-conflict #1793) lands, strip the transient cross-PR coordination NOTE from the revision-history cell.
Rejected findings
- tag-r01 -- The recommendation to add an ordering MUST ("evaluated after dependency resolution and BEFORE file materialization") is rejected: the
require_hashesenforcement insrc/apm_cli/install/pipeline.pyruns AFTER the integrate/materialization phase, so the spec deliberately says "at the point of the check" with no ordering claim. Adding a pre-materialization ordering MUST would make the normative text false against the implementation -- the opposite of the spec-vs-code fidelity this panel exists to protect.
Linter handoff: All 11 guardian linter checks pass on the post-fold artifact: anchor regex (89 strict anchors), manifest/Appendix-C/pytest-marker 4-way alignment, count sites consistent at 89 (84 MUST, 5 SHOULD) across Section 1.3, Appendix C trailer, and Appendix D, and the 6.8 -> 6.9 renumber introduces zero new broken links on the rendered slug model. Check 11 (modified .py files) is expected for this code+spec PR and is covered by the concurrent apm-review-panel run.
Full per-panel findings
Spec Oci Editor -- shocked_meter 8/10, confidence high
Summary: Clean additive editorial patch. The req-pl-013 fail-closed posture is sound (absent/unreadable lockfile trips the gate, non-local deps checked, local deps exempt via deployed-file hashes). The req-pl-014 advisory-skip-does-not-gate is a correct fail-closed posture. One recommended finding on per-source-type field binding for "recorded integrity hash" (now folded); one nit on cross-referencing the local-dep trust anchor.
New recommended findings (1)
- [oci-r1-001] 6.8 (req-pl-013) -- "recorded integrity hash" did not bind to a concrete lockfile field; a literal reading could let git-sourced entries pass while gating only registry entries.
Recommended fix: Name the field(s) that satisfy the predicate. (Folded as content_hash in 62258eb.)
New nit findings (1)
- [oci-n1-001] Local-dep exemption rationale does not cross-reference req-lk-012/req-lk-017 -- fix: append
(see [req-lk-012]). (Deferred v0.1.1.)
Preserved strengths confirmed
- Hash-envelope anchoring (req-lk-016) intact; fail-closed extraction posture (req-lk-013) still ordered before integration; supply-chain threat-to-req mapping remains comprehensive.
Spec Swagger Editor -- shocked_meter 8/10, confidence high
Summary: Clean additive editorial-patch. Both new governance MUST anchors are correctly enumerated across all count sites (Section 1.3, Section 6.9 trailer, Section 11.3.4 Governance, Appendix C + trailer) and the requirements manifest. Cross-references to the new Section 6.8 resolve; the 6.8 -> 6.9 renumber is fully propagated. RFC 2119 keyword discipline is sound.
New recommended findings (1)
- [sw-rec-r1-1] 6.8 (req-pl-014) -- the final clause binds an obligation on the default (off) state, so the requirement is only fully tested by exercising both on- and off-paths; suggest an informative testing note.
Recommended fix: Add an informative "exercise both paths" note. (Deferred v0.1.1.)
New nit findings (2)
- [sw-nit-r1-1] Appendix D 0.1.3 row ends with a transient sibling-PR NOTE that becomes noise post-merge. (Deferred v0.1.1.)
- [sw-nit-r1-2] 6.3.6 uses lowercase "fails closed" informatively, shadowing the normative MUST. (No change required.)
Preserved strengths confirmed
- Count-site consistency (89 / 84-MUST / 5-SHOULD); conformance-class enumeration completeness; monotonic anchor numbering; cross-reference integrity after renumber; RFC 2119 discipline.
Spec Pkgmgr Editor -- shocked_meter 7/10, confidence high
Summary: Clean additive editorial-patch. The two new merge-table rows exactly match the inheritance.py boolean-OR (tighten-not-relax) semantics. The req-pl-014 drift definition aligns with the existing lockfile drift model. One recommended finding on field-binding for "recorded integrity hash" (now folded); one nit on field-name precision.
New recommended findings (1)
- [pkg-001] 6.8 (req-pl-013) -- "recorded integrity hash" not bound to a specific lockfile field per source type; ambiguity could let two compliant-by-reading implementations diverge.
Recommended fix: Name the field(s) constituting the integrity hash. (Folded as content_hash in 62258eb.)
New nit findings (1)
- [pkg-n01] "deployed-file hashes" is ambiguous vs the schema term
local_deployed_file_hashes. (Deferred v0.1.1.)
Preserved strengths confirmed
- Lockfile determinism model intact; merge-table semantics match code exactly; reserved-slot discipline maintained; producer/consumer/governance class separation preserved.
Spec Tag Architect -- shocked_meter 8/10, confidence high
Summary: Clean additive editorial-patch. The two keys are layered correctly: non-normative 6.3.6 defers all normative force to a new Section 6.8 inserted before the conformance trailer (now 6.9). Cross-references, Appendix C rows, manifest entries, and counts are consistent. The security.integrity / security.audit namespaces leave clean forward-compatibility room for deferred signatures/provenance/SBOM work.
New recommended findings (2)
- [tag-r01] 6.8 (req-pl-013) -- recommends naming the phase boundary for the check (after resolution, before file materialization). (Rejected -- contradicts the implementation, which enforces after integrate; see Rejected findings above.)
- [tag-r02] 6.3.6 -- the field reference restates merge semantics that already live in the req-pl-006 table; defer to the table for single-source-of-truth. (Deferred v0.1.1.)
New nit findings (1)
- [tag-n01] 6.8 opening repeats "default-off"/"opt-in" already stated in 6.3.6. (Deferred v0.1.1.)
Preserved strengths confirmed
- Manifest stays in sync with Appendix C; section-number cross-references consistent after renumber; extension-model namespace leaves clean v0.2 room; non-normative 6.3.6 defers normative force to 6.8; tighten-not-relax merge invariant upheld.
This panel is advisory. It does not block merge. Re-apply the spec-review label after addressing feedback to re-run.
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 1 | 2 | Clean additive design; frozen-dataclass schema, OR-merge inheritance, fail-closed enforcement. One cross-module private-import is the only structural smell. |
| CLI Logging Expert | 0 | 1 | 1 | Install fail-closed errors route correctly through PolicyViolationError + CommandLogger; audit exit-code escalation is silent in text mode. |
| DevX UX Expert | 0 | 2 | 1 | Two well-scoped opt-in default-off booleans; naming/merge/defaults align with conventional package-manager mental models. |
| Supply Chain Security Expert | 0 | 1 | 1 | Both keys correctly fail-closed and default-OFF; gates on missing/empty hash and unreadable lockfile and indeterminate drift; OR-merge prevents downgrade. |
| OSS Growth Hacker | 0 | 0 | 1 | Clean additive PR that strengthens the governed-by-policy / secure-by-default-opt-in positioning; CHANGELOG is capability-framed. |
| Auth Expert | 0 | 0 | 0 | pipeline.py change is auth-adjacent by location but touches no authentication behaviour; no token/credential surface read or logged. |
| Doc Writer | 0 | 2 | 2 | Docs/spec accurately describe the code; spec count math 87->89 internally consistent. Minor completeness/consistency gaps only. |
| Test Coverage Expert | 0 | 2 | 0 | Solid unit coverage (35 tests); no integration-tier test exercises the full install pipeline or audit command end-to-end. Recommended follow-up. |
| Performance Expert | 0 | 0 | 1 | Effectively zero-cost on the default-OFF path; when ON, an avoidable lockfile re-parse but otherwise a pure O(n) string-presence scan. No hot-path regression. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Supply Chain Security Expert] Align APM_POLICY_DISABLE env-var check to == '1' -- audit.py:793 uses truthy os.environ.get instead of == '1' like the rest of the codebase; any non-empty value (including '0' or 'false') disables the gate, an accidental fail-open footgun. One-line fix, highest signal.
- [Python Architect] Promote _redact_url_credentials to a public util -- cross-module import of a private helper from .mcp.registry into install/integrity.py couples the integrity gate to a private function in an unrelated sub-package; a rename there silently breaks the error path. Promote to apm_cli.utils or inline.
- [CLI Logging Expert] Emit a verdict line on audit exit-code escalation in text mode -- when fail_on_drift flips exit 0 to 1 in text format, no final line explains the escalation; a terminal user sees drift output but no verdict. One stderr line closes the gap.
- [Test Coverage Expert] Add integration tests for require_hashes and fail_on_drift end-to-end -- 35 unit tests prove logic with mocked boundaries; integration tests through the real install pipeline and audit command would catch call-site wiring drift. Non-blocking given default-off, but valuable as adoption grows.
- [Doc Writer] Sync policy-schema.md and the published JSON Schema -- reference docs omit the indeterminate-drift escalation behaviour the spec and code implement, and the published JSON Schema does not yet encode the two new keys. Cosmetic consistency gap, not a correctness issue.
Architecture
classDiagram
direction LR
class ApmPolicy {
<<ValueObject>>
+security SecurityPolicy
}
class SecurityPolicy {
<<ValueObject>>
+audit AuditPolicy
+integrity IntegrityPolicy
}
class AuditPolicy {
<<ValueObject>>
+on_install str
+fail_on_drift bool
}
class IntegrityPolicy {
<<ValueObject>>
+require_hashes bool
}
class PolicyViolationError {
<<Exception>>
}
class enforce_require_hashes {
<<Pure>>
+__call__(deps, enabled) None
}
class unhashed_dependencies {
<<Pure>>
+__call__(deps) list
}
class _enforce_require_hashes_pipeline {
<<IOBoundary>>
+__call__(ctx) None
}
class _resolve_fail_on_drift {
<<IOBoundary>>
+__call__(project_root) bool
}
class LockFile {
+read(path) LockFile
+get_package_dependencies() list
}
class LockedDependency {
<<ValueObject>>
+source str
+content_hash str
+repo_url str
}
ApmPolicy *-- SecurityPolicy
SecurityPolicy *-- AuditPolicy
SecurityPolicy *-- IntegrityPolicy
_enforce_require_hashes_pipeline ..> LockFile : reads
_enforce_require_hashes_pipeline ..> enforce_require_hashes : delegates
_enforce_require_hashes_pipeline ..> PolicyViolationError : raises
enforce_require_hashes ..> unhashed_dependencies : delegates
enforce_require_hashes ..> LockedDependency : inspects
_resolve_fail_on_drift ..> ApmPolicy : reads
note for IntegrityPolicy "OR-merge in inheritance: once true stays true"
note for AuditPolicy "fail_on_drift: OR-merge, governs audit exit code"
class IntegrityPolicy:::touched
class AuditPolicy:::touched
class enforce_require_hashes:::touched
class unhashed_dependencies:::touched
class _enforce_require_hashes_pipeline:::touched
class _resolve_fail_on_drift:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["apm install (CLI entry)"] --> B["run_install_pipeline"]
B --> C["LockfileBuilder.build_and_save [FS]"]
C --> D{"policy.security.integrity.require_hashes?"}
D -- off/no-policy --> E["continue to deploy phase"]
D -- on --> F["_enforce_require_hashes(ctx) [FS]"]
F --> G["LockFile.read(lockfile_path) [I/O]"]
G -- None --> H["raise PolicyViolationError (fail-closed)"]
G -- ok --> I["enforce_require_hashes(deps, enabled=True)"]
I --> J{"unhashed_dependencies empty?"}
J -- yes --> E
J -- no --> K["raise RuntimeError -> PolicyViolationError"]
K --> L["pipeline error handler renders + exits 1"]
H --> L
M["apm audit (CLI entry)"] --> N["_audit_content_scan"]
N --> O{"drift_failed and exit_code==0?"}
O -- no --> P["return exit_code"]
O -- yes --> Q["_resolve_fail_on_drift(project_root) [I/O]"]
Q --> R{"policy.security.audit.fail_on_drift?"}
R -- True --> S["exit_code = 1"]
R -- False/error --> P
S --> P
sequenceDiagram
participant User
participant CLI as apm install
participant Pipeline as run_install_pipeline
participant Lock as LockfileBuilder
participant Gate as _enforce_require_hashes
participant Integrity as enforce_require_hashes
User->>CLI: apm install
CLI->>Pipeline: run_install_pipeline(ctx)
Pipeline->>Lock: build_and_save()
Lock-->>Pipeline: lockfile written
Pipeline->>Gate: _enforce_require_hashes(ctx)
Gate->>Gate: check policy enabled
alt require_hashes OFF
Gate-->>Pipeline: no-op return
else require_hashes ON
Gate->>Gate: LockFile.read(path)
Gate->>Integrity: enforce_require_hashes(deps, enabled=True)
alt all hashed
Integrity-->>Gate: return (pass)
Gate-->>Pipeline: return
else missing hashes
Integrity-->>Gate: raise RuntimeError
Gate-->>Pipeline: raise PolicyViolationError
Pipeline-->>CLI: exit 1
end
end
Pipeline-->>CLI: continue deploy
Recommendation
Ship now. Zero blocking findings from any of the 9 panelists. Both keys are additive, default-OFF, fail-closed, spec-backed, and covered by 35 unit + 2 spec-conformance tests with full CI green. The recommended follow-ups above are non-blocking polish items that do not change the ship decision and can land as a fast-follow PR within the same release cycle.
Full per-persona findings
Python Architect
- [recommended] Cross-module import of private helper _redact_url_credentials at
src/apm_cli/install/integrity.py:52
integrity.py imports a private function (leading underscore) from.mcp.registry, an unrelated sub-package; a rename or refactor there silently breaks the error path, and there is no public contract guaranteeing stability.
Suggested: Promote_redact_url_credentialsto a public utility (e.g.apm_cli.utils.url.redact_url_credentials) or inline the trivial implementation. - [nit] Docstring references the re-export path for PolicyViolationError at
src/apm_cli/install/pipeline.py:325
The canonical definition lives inapm_cli.install.errors; pointing at the back-compat re-export may confuse a reader grepping for the source of truth. - [nit] fail_on_drift placement asymmetry at
src/apm_cli/policy/schema.py:194
fail_on_driftis a field onAuditPolicywhilerequire_hashesgets its own newIntegrityPolicy; the asymmetry is intentional but worth one clarifying inline comment.
CLI Logging Expert
- [recommended] Silent exit-code escalation in text-mode audit at
src/apm_cli/commands/audit.py:979
When fail_on_drift flips the exit code 0->1 in text format, no message explains the escalation; the verdict (pass/fail) should be the last thing printed.
Suggested: Append a one-liner after setting exit_code=1, e.g.[x] drift detected -- exit escalated by security.audit.fail_on_drift. - [nit] Fix hint circular in lockfile-unreadable case at
src/apm_cli/install/pipeline.py:348
The message says re-run install while the user is already inside install; consider "delete the corrupt lockfile and re-run" or drop the hint.
DevX UX Expert
- [recommended] Exit-code help text omits policy-driven drift escalation at
src/apm_cli/commands/audit.py:1199
The --help docstring says drift-only is advisory (exit 0); with fail_on_drift enabled that same drift yields exit 1, but --help is silent.
Suggested: Note that exit 1 also covers policy-gated drift. - [recommended] Error message suggests re-running the install that just failed at
src/apm_cli/install/integrity.py:55
Enforcement runs inside the same install pipeline that just wrote the lockfile; "re-run install" can loop. The dep-name list is good; the recovery action needs to be achievable on the first try. - [nit] Table column key path mismatch at
docs/src/content/docs/reference/policy-schema.md:168
The row readsintegrity.require_hashesbut other rows are relative to the security section; add thesecurity.prefix or note the implicit scope.
Supply Chain Security Expert
- [recommended] _resolve_fail_on_drift uses a truthy check for APM_POLICY_DISABLE at
src/apm_cli/commands/audit.py:793
The rest of the codebase tests== "1"; bare truthy means any non-empty value (e.g. '0', 'false') disables the gate -- an accidental fail-open footgun.
Suggested: Align to== "1". - [nit] _resolve_fail_on_drift swallows all exceptions returning False at
src/apm_cli/commands/audit.py:799
The design intent (transient failure must not convert advisory drift into a hard failure) is documented; alogging.debugline would let operators diagnose a permanently-open gate. Explicitly not blocking.
OSS Growth Hacker
- [nit] CHANGELOG entry is dense at
CHANGELOG.md:10
For the release narrative, consider a one-sentence capability hook before the detail so it is quotable by security-minded adopters.
Auth Expert
No findings. pipeline.py change is auth-adjacent by location but touches no authentication behaviour; the new call site operates purely on lockfile hash-presence and policy, never reading, writing, passing, or logging credentials. The error path uses _redact_url_credentials to sanitise URLs.
Doc Writer
- [recommended] policy-schema.md fail_on_drift description omits the indeterminate-drift escalation at
docs/src/content/docs/reference/policy-schema.md:166
audit.py escalates ondrift_check.passed is False, which covers both detected drift AND a drift check that could not run; the reference page should be at least as complete as the normative spec on observable exit behaviour. - [recommended] Published policy-v0.1.schema.json does not encode the two new keys at
docs/src/content/docs/specs/openapm-v0.1.md:1335
openapm-v0.1.md now asserts these as v0.1 governance MUSTs while the companion JSON Schema omits them; the runtime validator already enforces the keys, so this is a corpus-consistency gap, not a runtime defect.
Suggested: Add the keys to the JSON Schema or note its encoding is advisory/deferred. - [nit] YAML comment drops the non-local qualifier at
docs/src/content/docs/reference/policy-schema.md:203
integrity.py skipssource == "local"entries; the unqualified comment could read as if local deps are also gated. - [nit] Exit-status wording inconsistent at
docs/src/content/docs/reference/policy-schema.md:210
"escalates the exit code to 1" is correct only when there are no content findings; the spec's "non-zero" phrasing is the precise contract.
Test Coverage Expert
- [recommended] Install pipeline lacks an integration test for the require_hashes fail-closed gate
Unit tests exercise the gate with mocked LockFile.read; no integration test wires a real policy fixture through run_install_pipeline with a lockfile missing a hash to prove the gate fires at the correct phase.
Proof (missing at):tests/integration/test_install_require_hashes_e2e.py-- proves: install fails closed when a non-local lockfile entry lacks a content hash and require_hashes is on [secure-by-default] - [recommended] Audit command lacks an integration test for fail_on_drift exit-code escalation
Unit tests mock _resolve_fail_on_drift; no integration test exercisesapm auditend-to-end with fail_on_drift enabled and a drifted workspace to confirm the non-zero exit propagates.
Proof (missing at):tests/integration/test_audit_fail_on_drift_e2e.py-- proves: apm audit exits non-zero when workspace content drifts and fail_on_drift is enabled end-to-end [secure-by-default,governed-by-policy]
Performance Expert
- [nit] Avoidable lockfile re-parse when require_hashes is ON at
src/apm_cli/install/pipeline.py:342
The gate re-parses the YAML lockfile from disk immediately after LockfileBuilder just wrote it; the resolved deps are already in memory. Cosmetic today (default-off, page-cache-hot) but material if the key is ever promoted to default-ON.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Reconcile cross-PR normative-count collision after #1794 (req-pl-013/014) and #1801 (Mode B fail-closed CI) merged to main. Union all shared count sites to cumulative 90 (req-pl-013, req-pl-014, req-pl-015 all present; 85 MUST, 5 SHOULD). Bump req-pl-015 revision-history row to 0.1.4 to keep Appendix D monotonic. Regenerated CONFORMANCE.{json,md}. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…conflict (#1793) * feat(audit): surface unmanaged artifacts with reason, type, and deny-conflict Extend the existing _check_unmanaged_files policy check (one unified report, not a parallel scan engine) so apm audit answers "what is here that APM did not put here?": - Enrich each finding with a factual reason ("not tracked in apm.lock.yaml") and a deny-conflict note ("matches deny rule (<pattern>)") when the path matches the policy's own dependencies.deny / mcp.deny. - Lazily classify the primitive type (skill/agent/instruction/mcp) of the already-flagged files only -- never the whole tree. - Surface deny-conflicts through a shared first_matching_pattern matcher reused by the dependency/MCP deny-list checks (no second matcher). - Add unmanaged_files.exclude (glob allow-list) to suppress known harness-managed paths; carried through inheritance merge as a union. - Guard traversal against symlinks escaping the workspace. This is drift / divergence visibility, not supply-chain-attack prevention -- the lockfile is hand-editable YAML. Report only; APM never removes or blocks a flagged file. Part of #1774 Closes #1775 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): record PR number for unmanaged-artifacts feature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(audit): address PR #1793 review -- symlink-dir guard, exclude null-transparency, classifier precedence Fold three Copilot review findings on the unmanaged-artifacts check: - Symlink-dir traversal guard (security): switch the scan from Path.rglob (which can recurse into directory symlinks) to os.walk(followlinks=False) -- the house pattern from security/gate.py -- so a symlinked directory resolving outside the workspace is never traversed. The file-symlink escape guard now applies only to file symlinks. Adds a regression test asserting a symlinked dir's contents never surface. - exclude null-transparency: parser now mirrors the deny/require pattern so `exclude: null` (or an absent key) -> None (transparent in merge), `exclude: []` -> () (explicit override), `exclude: [..]` -> tuple. Previously `null` collapsed to (), breaking the documented inheritance semantics. Still passes the #1791 coverage guard. - Classifier precedence: explicit filename conventions (.agent.md / .instructions.md / mcp.json / SKILL.md) now win before directory-segment hints, and MCP detection is narrowed to known config filenames / a `.mcp/` root -- so a path under a dir merely named `mcp` (e.g. .github/agents/mcp/rogue.agent.md) classifies as agent, not mcp. Strict TDD (red tests first), mutation-break verified on each new assert. ASCII-only, report-only. Part of #1774 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(audit): correct deny-rule doc example, add next-action hint, harden type trap Fold the apm-review-panel advisory on the unmanaged-files audit: - docs(policy-reference): the deny-conflict example claimed `matches deny rule (rogue*)` for `.claude/skills/rogue/SKILL.md`, but the matcher fully anchors patterns (`rogue*` -> `^rogue[^/]*$`) and the conflict check tests only the rel path and basename -- neither starts with `rogue`, so that line could never be emitted. Use `**/rogue/**`, which matches via the `/rogue/` segment so the documented output is reproducible. - docs(policy-reference): add `.kiro` to the documented default governance directories so the list matches `_DEFAULT_GOVERNANCE_DIRS`; note the `mcp` directory-name narrowing on the type-tag bullet. - feat(policy): append one next-action hint to a non-empty unmanaged-files report (track with `apm install <ref>` / suppress via `unmanaged_files.exclude`) so a flagged file is self-resolving. Covered by a new regression-trap test (mutation-break verified). - docs(policy-schema): correct the `exclude` default to `null` (null-transparent in the extends merge) to match the model. - test(policy): tighten the type-tag assertion to `[type: agent]` so the trap fails if the tag is dropped (the path itself contains `agent`). - docs(code): clarify in the docstring that the dependency deny side is defaults-inclusive (`effective_deny`) while MCP uses raw `mcp.deny`, and document the basename fallback intent. Report-only drift-visibility behavior is unchanged; the traversal and matcher logic is untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(spec): cite req-pl-015 for unmanaged-artifact surfacing Author OpenAPM v0.1 requirement req-pl-015 (Section 6.3.5, governance MUST) codifying the unmanaged-artifact surfacing behavior added in this PR, resolving the Mode B spec-conformance gate honestly (spec is the contract) rather than waiving it. req-pl-015 governs reporting COMPLETENESS only: a governance implementation evaluating policy over a populated primitive target tree MUST surface every file under a managed primitive target directory that is neither recorded in apm.lock.yaml nor matched by a configured unmanaged_files.exclude glob, each carrying its unmanaged reason, a dependency/MCP deny-conflict note where applicable, and a determinable inferred primitive type; an excluded path MUST NOT be surfaced. Enforcement stays governed by unmanaged_files.action -- this is not an enforcement claim. Citation ritual (all sites edited for orphan_check + linter-check-6): - Section 6.3.5 anchor + normative prose. - Section 6.4 merge table: unmanaged_files.exclude row (union, dedup, parent order preserved -- matches inheritance._union). - Section 1.3 + Appendix C count 87 -> 88 (83 MUST); Appendix C row; Section 6.8 governance trailer; Appendix D erratum row. - Manifest entry after req-pl-012; regenerated CONFORMANCE.{md,json}. - New @pytest.mark.req("req-pl-015") behavioral test binding the spec MUST to _check_unmanaged_files (reason, type, deny-conflict, exclude). Cross-PR note: sibling spec-citation PR #1794 also edits the shared count sites; whichever merges second needs a trivial count reconcile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(spec): fold guardian findings on req-pl-015 (sub-clauses, absent-type, additive exclude) Restructure req-pl-015 into lettered sub-clauses (a)/(b)/(c) so each obligation is individually citable; add the absent-type omission MUST; clarify the deny-conflict note is supplemental enrichment; cross-ref the exclude matcher to Section 6.5 (no new dialect pin); qualify the 6.4 merge-table exclude row as additive union (child cannot clear parent; null and [] both preserve parent); relabel the Appendix D row as a semver-zero normative addition (not an informative erratum); reserve req-pl-013/014 in the manifest for concurrent in-flight work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(spec): fold guardian round-2 editorial polish on req-pl-015 Apply the three zero-risk fold-now items from the apm-spec-guardian round-2 synthesis (ship_decision=fold_and_ship, shocked_meter_avg=8.0, zero blocking): state the exclude-merge dedup key is byte-exact on each pattern's UTF-8 string; remove an orphan line break in sub-clause (b); replace the em-dash in the (b) reason clause with a parenthetical. No normative, anchor, count, or conformance-test change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fold(review-panel): clarify exclude additive-merge in reference docs; type-annotate locals apm-review-panel re-run folds (in-scope, non-blocking): - [recommended/doc-writer] policy-schema.md + policy-reference.md exclude merge rows now state additive-only: null AND [] both preserve the parent list (unlike deny/require, a child cannot clear an inherited exclude), matching the openapm-v0.1 6.4 merge-table row and _merge_unmanaged_files. - [nit/python-architect] annotate deployed/deployed_dir_prefixes locals as set[str] / list[str] in _check_unmanaged_files. Declined (noted): doc-writer (a) absolute-framing carve-out nit -- guardian already passed the absolute wording; the 'when it evaluates policy' preamble covers symlink-escape/scan-cap degraded modes; low value vs reopening blessed spec prose. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…h-1670 Faithful union resolution: keep main's new [Unreleased] Added entries (#1793 audit unmanaged-files, #1810 ADO marketplace, #1770 antigravity target, #1794 security policy keys) AND re-insert this PR's MCP extra-passthrough + denylist entry (#1670/#1765) in Keep-a-Changelog order. All adapter/integrator denylist wiring preserved. Co-authored-by: Sergio Sisternes <sergio.sisternes@epam.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sync the 800-line/complexity tightening branch with origin/main tip 788a09a (8 commits ahead of merge-base 45843c3): SBOM export + declared-license (#1820), dompurify bump (#1789), audit-unmanaged (#1793), ADO sourceBase (#1810), Antigravity target (#1770), marketplace token (#1763), spec-conformance (#1801), declared-license and integrity keys (#1794/#1777). Conflict resolution preserves the strangler-fig extraction: HEAD's relocations into sibling _*.py modules win, with main's feature additions folded into the new homes. Notable folds: - hook_merge.py: thread container key + antigravity dispatch. - audit: route fail_on_drift + LockFile through the audit module so test monkeypatches on apm_cli.commands.audit.* still take effect. Resolve merge-introduced CI regressions under the tightened gates: - ruff complexity: _classify_primitive_type (PLR0911), validate_policy (C901/PLR0912 via _validate_security), _audit_content_scan (PLR0912 via _run_drift_detection). - file-length <=800: split spdx_data.py (_spdx_exception_ids.py), policy_checks.py (_policy_checks_unmanaged.py), pack.py render helpers (into _pack_ops.py); all re-exported for the patch contract. Local CI mirror green: ruff check/format, pylint R0801 10/10, auth-signals, file-length<=800, full unit suite 17225 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Problem
apm-policy.ymlcan express several governance floors (audit-on-install, registry routing, scanner restrictions), but it cannot yet declare two integrity guarantees that the install/audit pipeline already enforces operationally:apm auditto be a hard CI/local gate on workspace drift has to remember--ci; a bareapm auditrenders drift but exits 0.Both behaviors exist in the code today -- they just are not declarable as policy.
What changed: two keys, and only two
Both are additive, optional, and default off, nested under the existing
security:namespace (no new top-level namespace, no top-levelaudit:):security.integrity.require_hashes(bool, default off) -- require lockfile content hashes for all installs. A missing/empty hash on a non-local lockfile entry is a failure (fail-closed), not a silent pass.security.audit.fail_on_drift(bool, default off) -- make a bareapm auditexit non-zero when workspace content drifts from the lockfile. Sits alongside the existingsecurity.audit.on_install.Why only two
Every field in the policy schema maps 1:1 to a concrete
apmcheck that enforces it today.require_hashesasserts hash-presence on the freshly-built lockfile entry (the install pipeline already computes + records hashes -- no second hashing pass).fail_on_driftreuses the existing drift detection and only changes the exit code -- it does not add a second drift pass. Keys for signatures / provenance / sbom are deliberately not added here (deferred to #1777): a key that validates but enforces nothing is a false-assurance liability.Fail-closed behavior
require_hashes: true+ any non-local locked entry with an empty/missingcontent_hash-> install fails closed (offenders named). Local dependencies are exempt. No-op when the key is off,--no-policy, or no policy resolves.fail_on_drift: true+ detected drift ->apm auditescalates exit code to 1. Default-off preserves today's advisory (rendered, exit 0) behavior.Both keys carry through policy inheritance with logical-OR / tighten-not-relax semantics: once a parent enables a key a child cannot relax it, and a child silent on the key preserves the parent's value (None-transparency). The structural merge coverage guard from #1791 stays green.
Test seams (TDD, red first)
tests/fixtures/policy/, canonicalize, and assert equality with a checked-in golden snapshot, proving old policies parse identically after the new keys are added (the additive / non-breaking claim).require_hashes/fail_on_driftdoes not override a parent that set them.require_hashes: true(and passes when present);fail_on_drift: truemakes a drifted workspace exit non-zero while default-off preserves current behavior.New on/off fixtures per key were added; a mutation-break was confirmed (forcing
require_hashesto treat a missing hash as a pass fails the suite) and restored.Validation evidence (all green)
All source files stay under 2450 lines; ASCII only.
Docs
docs/src/content/docs/reference/policy-schema.md: documented both keys (default off; what each enforces) + merge rules.packages/apm-guide/.apm/skills/apm-usage/governance.md: integrity/drift governance subsection.CHANGELOG.md: Unreleased### Addedentry.Spec citation (OpenAPM v0.1)
Both keys are now first-class normative requirements in the OpenAPM v0.1 specification (maintainer governance call: author the citation, not a waiver):
security.integrity.require_hashes, MUST, governance) and req-pl-014 (security.audit.fail_on_drift, MUST, governance) land under a new Section 6.8 "Integrity controls", with a non-normative 6.3.6securityfield reference, two req-pl-006 merge-table rows (logical OR / tighten-not-relax), an Appendix C index row each, and a requirements-manifest entry each. Statement count 87 -> 89 (84 MUST, 5 SHOULD).apm-spec-guardianadversarial panel returnedfold_and_ship(shocked-meter avg 7.75/10, zero blocking across all four reviewers). The single convergent recommended finding -- bind the phrase "recorded integrity hash" to the concretecontent_hashlockfile field the implementation checks -- was folded.Part of #1774