feat(audit): close audit-blindness gap for local .apm/ content (#887)#889
feat(audit): close audit-blindness gap for local .apm/ content (#887)#889danielmeppiel merged 11 commits intomainfrom
Conversation
Implement self-entry virtual lockfile mechanism + includes manifest field + hash drift detection + policy gate, addressing the audit-blindness crisis identified by the CISO + crisis panel. ## Wave 1 (foundation) - Lockfile self-entry: synthesize LockedDependency for local content (repo_url=<self>, source=local, local_path=., is_dev=true) at read boundary; pop+restore in to_yaml() with try/finally for byte-stable round-trip. - New get_package_dependencies() excludes self-entry for callers doing dependency-walk operations. - includes manifest field (None | 'auto' | List[str]) with parser. - Policy schema: require_explicit_includes (bool, default false). - Packer guard: skip source==local deps in apm bundles. ## Wave 2 (audit + policy + caller migrations) - _check_lockfile_exists: probes local_deployed_files so local-only repos don't fail. - _check_no_orphans: filters _SELF_KEY. - _check_content_integrity: re-reads files, compares SHA-256 against deployed_file_hashes; skips missing/symlinks/unhashed entries. - New _check_includes_consent: advisory [!] when includes undeclared but local content present (always passes). - New _check_includes_explicit in policy seam, threaded via conditional kwarg from policy_gate. - Migrated 6 callers from get_all_dependencies() to get_package_dependencies() to hide self-entry from walk-and-act operations. 47 new tests added across 5 files. 666 targeted tests pass. Refs: #887 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-trip (#887) Wave 3: verification tests + N1 fix. ## Scanner coverage (compute_deployed_hashes) 9 tests pinning the files-vs-hashes contract: - Every regular file has a corresponding hash entry. - Directories excluded; symlinks excluded (deterministic). - Empty files hashed (well-known SHA-256 of b''). - Hidden files (.mcp.json) hashed. - The set difference 'local_deployed_files - hashes.keys()' equals exactly the directory entries. Live confirmation on this repo: 26 files, 18 hashes, 8 directory diff (under .github/skills/) -- no regular file is missing a hash. ## Packer regression + N1 fix Architect's N1 finding: enrich_lockfile_for_pack() was serializing 'local_deployed_files' / 'local_deployed_file_hashes' verbatim into bundle lockfiles, causing a phantom self-entry on unpack whose files the unpacker would then fail to verify. Fix at bundle/lockfile_enrichment.py: strip both fields from the bundle lockfile dict after YAML round-trip and before pack: metadata serialization. Original LockFile object untouched. 4 regression tests + live 'apm pack' confirms bundle lockfile is clean. ## Integration round-trip (5 tests, all pass live) A. Install records self-entry (local_deployed_files + hashes). B. Audit clean install: content-integrity passes, includes-consent emits [!] advisory. C. Audit detects drift: tampered file -> exit 1 + 'hash-drift: <path>'. D. Includes declared (auto or list): no [!] advisory. E. Policy require_explicit_includes: blocks undeclared/auto. End-to-end via subprocess (matches existing test_local_install.py convention). 65 targeted tests pass. Refs: #887 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
) Wave 4 + critical fixes for the local-content audit work: - apm.yml: declare 'includes: auto' to consent to local .apm/ deployment governance for this repo's own dogfood. - pyproject.toml + uv.lock: bump to 0.10.0 (target release for #887). - CHANGELOG.md: Added/Fixed/Changed entries under [Unreleased]. - Docs: lockfile-spec.md ($4.5 Self-Entry Convention), manifest-schema.md ($3.9 'includes' field), governance-guide.md (remove drift-gap caveats now that hash verification ships), apm-usage/governance.md skill. Critical bug fixes discovered during dogfood: - cleanup.py: orphan loop iterates lockfile.dependencies which now includes the synthesized self-entry under '.'. Without a guard, every install was deleting all 26 deployed local files because '.' is never in apm.yml's external dependencies. Add explicit skip on _SELF_KEY before the orphan check. - lockfile.py: is_semantically_equivalent compared local_deployed_files but skipped local_deployed_file_hashes. Hash-only changes were treated as 'no change', so post-install hash refreshes never persisted, causing audit to report stale hash drift forever. Add the hash-dict comparison. Verified locally: install clean, audit 7/7 green, drift introduced detected, drift removed back to green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three-panel review (security, devx, logging) feedback applied:
Security:
- ci_checks.py:_check_content_integrity: gate every hash-verification
read through BaseIntegrator.validate_deploy_path so a forged lockfile
with a traversal path (e.g. '../../.env') cannot induce reads outside
managed locations or leak hashes via audit output.
DevX UX:
- _helpers.py:_create_minimal_apm_yml: scaffold 'includes: auto' so
'apm init' projects don't trip the includes-consent advisory the
moment a primitive lands in .apm/. Matches what manifest-schema.md
promises ('default for newly initialised projects').
- policy_checks.py:_check_includes_explicit: branch the error message
-- 'add includes: [...]' when manifest has no includes field, and
'replace includes: auto' only when the user actually wrote auto.
- ci_checks.py: render the synthesized self-entry as 'dep=<self>'
rather than the internal '.' constant in hash-drift details.
- Docs: bump check counts (6 baseline -> 7, 16 policy -> 17) across
governance-guide.md, apm-policy.md, ci-cd.md, ci-policy-setup.md.
Add includes-consent to enumerated baseline list. Document hash
drift detection in cli-commands.md ('What it detects').
- manifest-schema.md: correct 'rejected at parse time' to 'rejected
at install/audit time by the explicit-includes policy check'.
Logging:
- ci_checks.py:_check_content_integrity: build remediation message
conditionally so hash-only failures don't suggest 'apm audit --strip'
(which only strips Unicode).
- ci_checks.py: truncate hashes in hash-drift detail line for terminal
width (full hashes still in JSON output).
- ci_checks.py:_check_includes_consent: drop '[!]' prefix from
message text -- the audit table renderer owns the status column;
embedding [!] inside a passed=True row produced contradictory
'[+] ... [!] ...' output. Replace 'consent check N/A' with
'includes consent check skipped' (no jargon).
- test_file_scanner.py: replace section-sign character with ASCII.
All 5383 unit tests pass (1 pre-existing flaky MCP scope test
unrelated). Live verification: install clean, audit 7/7 green,
drift detection works, 'apm init -y' scaffolds 'includes: auto'.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Closes the apm audit --ci integrity gap for locally-deployed .apm/ content by projecting local lockfile state into a virtual self-entry and extending baseline audit to hash-verify all managed files (including local ones), plus adding an includes: manifest consent signal with optional policy enforcement.
Changes:
- Synthesize a virtual
./<self>lockfile dependency in-memory and update key call sites to skip it where appropriate. - Extend baseline
content-integrityto include per-file SHA-256 drift detection; addincludes-consentbaseline advisory andmanifest.require_explicit_includespolicy check. - Add/adjust tests and docs to cover the new audit behavior, policy seam wiring, and bundling safeguards (strip local-content metadata; skip local-source deps in bundles).
Show a summary per file
| File | Description |
|---|---|
| uv.lock | Bumps apm-cli version to 0.10.0. |
| pyproject.toml | Bumps project version to 0.10.0. |
| apm.yml | Bumps repo manifest version and adds includes: auto. |
| apm.lock.yaml | Updates local deployed file hashes (dogfood drift baseline). |
| CHANGELOG.md | Adds Unreleased entries for self-entry/includes/audit hash drift and related fixes. |
| docs/src/content/docs/reference/manifest-schema.md | Documents new includes: manifest field and policy interaction. |
| docs/src/content/docs/reference/lockfile-spec.md | Documents the in-memory self-entry convention and invariants. |
| docs/src/content/docs/reference/cli-commands.md | Updates apm audit --ci baseline check enumeration and hash drift behavior. |
| docs/src/content/docs/integrations/ci-cd.md | Updates baseline/policy check counts (7 + 17). |
| docs/src/content/docs/guides/ci-policy-setup.md | Updates baseline/policy check counts and text references. |
| docs/src/content/docs/enterprise/governance-guide.md | Updates governance matrix/counts and adds explicit-includes policy documentation. |
| docs/src/content/docs/enterprise/apm-policy.md | Updates CI-time baseline check count in docs. |
| packages/apm-guide/.apm/skills/apm-usage/governance.md | Extends governance skill docs with explicit-includes knob + includes: explanation. |
| src/apm_cli/deps/lockfile.py | Adds _SELF_KEY, synthesizes self-entry in from_yaml(), excludes it in new get_package_dependencies(), updates equivalence and installed-paths guard. |
| src/apm_cli/policy/ci_checks.py | Extends baseline checks: local-only lockfile relevance, orphan skip for self, hash drift verification, adds includes-consent advisory. |
| src/apm_cli/policy/policy_checks.py | Adds explicit-includes policy check and a seam param to run it only when manifest context is provided. |
| src/apm_cli/policy/parser.py | Parses/validates manifest.require_explicit_includes. |
| src/apm_cli/policy/schema.py | Adds require_explicit_includes to policy schema. |
| src/apm_cli/install/phases/policy_gate.py | Threads ctx.apm_package.includes into dependency policy checks when available. |
| src/apm_cli/install/phases/cleanup.py | Protects the synthesized self-entry from orphan cleanup deletion. |
| src/apm_cli/bundle/packer.py | Skips source=local dependencies when collecting deployed files for apm bundles. |
| src/apm_cli/bundle/lockfile_enrichment.py | Strips local_deployed_files + local_deployed_file_hashes from bundle lockfile YAML. |
| src/apm_cli/models/apm_package.py | Adds includes field parsing/validation to APMPackage. |
| src/apm_cli/integration/skill_integrator.py | Uses get_package_dependencies() to avoid <self> in ownership maps. |
| src/apm_cli/integration/mcp_integrator.py | Uses get_package_dependencies() to avoid <self> during transitive collection. |
| src/apm_cli/commands/_helpers.py | apm init scaffolds includes: auto and skips self-entry in install-path expectations. |
| src/apm_cli/commands/uninstall/engine.py | Uses get_package_dependencies() to avoid self-entry crashes in uninstall flows. |
| src/apm_cli/commands/deps/cli.py | Uses get_package_dependencies() so self-entry does not show up in apm deps tree. |
| tests/unit/test_lockfile_self_entry.py | Adds unit coverage for self-entry synthesis + serialization invariants. |
| tests/unit/test_self_entry_caller_guards.py | Regression tests for representative callers skipping the self-entry. |
| tests/unit/test_packer.py | Adds tests for excluding local-source deps in bundles and stripping local-content fields. |
| tests/unit/test_deps_list_tree_info.py | Adds guard test ensuring deps tree hides self-entry. |
| tests/unit/test_ci_checks.py | Adds baseline audit coverage for hash drift + local-only repo behavior + includes-consent. |
| tests/unit/test_audit_policy_command.py | Updates baseline check count expectations in CI/policy JSON tests. |
| tests/unit/test_apm_package.py | Adds unit tests for includes parsing and behavior. |
| tests/unit/install/test_policy_gate_phase.py | Tests that policy_gate threads manifest_includes correctly. |
| tests/unit/policy/test_policy_checks.py | Updates expected policy check count to 17 and imports explicit-includes check. |
| tests/unit/policy/test_run_dependency_policy_checks.py | Adds seam-level tests for explicit-includes check gating. |
| tests/unit/policy/test_parser.py | Adds parser/validator tests for require_explicit_includes. |
| tests/unit/install/test_file_scanner.py | Adds coverage tests for compute_deployed_hashes directory/symlink/empty-file behavior. |
| tests/integration/test_local_content_audit.py | Adds end-to-end subprocess tests for local-content install + audit drift detection + policy enforcement. |
Copilot's findings
Comments suppressed due to low confidence (1)
docs/src/content/docs/reference/manifest-schema.md:180
- This section claims
apm auditemits an[!]includes-consent advisory, but the current CI renderer only shows pass/fail status symbols andincludes-consentreturnspassed=Truewith no[!]prefix in the message. Either update the implementation to include[!]in theincludes-consentmessage (including JSON/SARIF), or adjust this doc text to describe the advisory without asserting a specific status symbol.
Declares which local `.apm/` content the project consents to publish when packing or deploying. Three forms are supported:
1. **Undeclared** -- field omitted. Legacy behaviour: all local `.apm/` content is published as if `auto` were set. `apm audit` emits an `[!]` includes-consent advisory whenever local content is deployed under this form.
2. **`includes: auto`** -- explicit consent to publish all local `.apm/` content via the file scanner. No path enumeration required. Default for newly initialised projects.
- Files reviewed: 40/41 changed files
- Comments generated: 5
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Agent-Logs-Url: https://github.com/microsoft/apm/sessions/fb9d2cd4-ba19-4ecb-b6cd-743cd5743bdd Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com>
Note on follow-upThis PR is the foundation that Epic #898 builds on. The Epic-PR will rebase on top of the work landed here and extend it with:
No scope change requested here; this PR ships as designed and closes #684 + #887. The Epic owns the next layer. |
# Conflicts: # CHANGELOG.md # apm.lock.yaml
Found issue: shared/apm.md restore step crashes on PRs that modify
|
|
Update on the fix layering: after re-examining the architecture, the bridge-step approach in gh-aw was the wrong layer. We control both
The single-line |
Final diagnosis (corrected)Verified -- the bug is isolated to What's actually happening
Single-layer fixmicrosoft/apm-action#26 -- ship as v1.5: install the Tactical mitigation while v1.5 shipsA small PR will add a post-restore What changed vs. the prior comment
References:
|
…rkspace (#27) * fix(restore): install APM and unpack via apm CLI to avoid dirtying workspace In restore mode the action previously skipped 'ensureApmInstalled()' (runner.ts:90 — 'skip APM installation entirely'), forcing extractBundle through its raw 'tar xzf --strip-components=1' fallback. That fallback extracted the *entire* bundle — including 'apm.lock.yaml' and 'apm.yml' — into 'working-directory'. When 'working-directory' was a git checkout (the default '${{ github.workspace }}'), those tracked files became dirty and any subsequent 'git checkout' aborted with: error: Your local changes to the following files would be overwritten by checkout: apm.lock.yaml This broke every 'pull_request_target' agentic workflow whose triggering PR modified the lockfile — i.e., the canonical first-run PR for any new APM adopter (originally surfaced via gh-aw 'shared/apm.md' callers). The 'apm unpack' CLI honors the bundle contract: it copies only files listed in the lockfile's 'deployed_files' (primitives + apm_modules) and never writes the lockfile or manifest to the output dir. The fix is to always install APM in restore mode so extractBundle takes the verified 'apm unpack' path. Tool-cached install adds at most a single small download per runner (negligible vs. agent-job cost), and we get bundle integrity verification for free. The previous 'unverified — install APM for integrity checks' warning was already advertising the fallback as the inferior code path. Defense-in-depth: the tar fallback (now reached only if 'apm install' itself fails) also gets '--exclude=apm.lock.yaml --exclude=apm.lock --exclude=apm.yml' so it can never dirty a workspace either. Tests: - new unit test in runner.test.ts asserts ensureApmInstalled() runs before extractBundle() in restore mode - existing bundler tar-fallback test extended to assert exclude flags - new integration job 'test-restore-clean-workspace' in CI reproduces the gh-aw scenario end-to-end on a real ubuntu-latest runner: pack a bundle, commit a tracked apm.lock.yaml, restore the bundle, then assert (a) 'git status' shows no modifications to apm.lock.yaml / apm.yml and (b) 'git checkout <baseline-sha> -- .' succeeds without the regression error Refs: #26 Refs: microsoft/apm#889 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * review: drop v1.5 reference + sharpen unverified-restore log message Addresses two low-confidence Copilot review nits on #27: - runner.ts:94 — drop the 'added in v1.5' marker from the rationale comment so it doesn't go stale on backports/cherry-picks; the rationale stands on its own. - runner.ts:116 — restore mode now installs APM up-front, so the unverified branch only runs if that install failed transiently and extractBundle fell through to tar. Point operators at the install logs instead of telling them to install APM. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Tracking the follow-up cleanup in #902 — bump |
v1.4.2 fixes the restore-mode workspace pollution that caused #901 and the CI failure surfaced in #889. With v1.4.2, restore mode installs APM and uses 'apm unpack' to extract bundles, writing only files declared in the lockfile's deployed_files instead of overwriting tracked apm.lock.yaml / apm.yml / apm_modules in the caller's git workspace. - shared/apm.md: bump both pack + restore steps to @v1.4.2 - pr-review-panel.lock.yml: regenerated via 'gh aw compile' (SHA pin updated) - actions-lock.json: SHA pin updated Closes #902. Upstream: microsoft/apm-action#27, release v1.4.2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🌱 OSS Growth Hacker — Findings1 · Feature–adoption fit: strong enterprise unlock, careful on community frictionEnterprise signal is excellent. The Community friction is managed, but with a gap. The 2 · Scaffolding:
|
🔐 Supply-Chain Security Review — PR #889Finding 1 ·
|
| Call site | Self-entry handling | Verdict |
|---|---|---|
packer.py:128 |
Filters source == "local" immediately after |
✅ Safe |
unpacker.py:129 |
Bundle lockfile has local_deployed_files stripped → no self-entry synthesized |
✅ Safe |
plugin_exporter.py:470 |
Self-entry has is_dev=True → skipped by dev filter |
✅ Safe |
resolve.py:62 |
Verbose logging only | ✅ Safe |
lockfile.py:274 (to_yaml) |
Self-entry popped before serialization, restored in finally |
✅ Safe |
lockfile.py:418 (get_installed_paths) |
Explicit local_path == _SELF_KEY: continue guard |
✅ Safe |
All callers are accounted for. The packer.py and plugin_exporter.py sites use implicit guards (field-value filters) rather than the explicit _SELF_KEY sentinel — this is acceptable given the defense-in-depth, but see Finding 2 about making the sentinel a stable contract.
Finding 4 · Self-entry repo_url="<self>" cannot be fetched — but to_dependency_ref() is fragile (Low)
The synthesized self-entry at lockfile.py:316-324 uses repo_url="<self>". I traced all paths where to_dependency_ref() could be called on a LockedDependency:
get_installed_paths()(lockfile.py:421) — guarded: skipslocal_path == _SELF_KEYat line 419._dep_install_path()(plugin_exporter.py:399) — guarded: self-entry skipped byis_dev=True._helpers.py:137— guarded: usesget_package_dependencies().
If any of these guards were bypassed and to_dependency_ref() were called on the self-entry, get_install_path() would attempt Path(".").name → "" → validate_path_segments("", ..., reject_empty=True) → PathTraversalError. This is fail-closed — good. No network fetch is possible because the path validation throws before any URL resolution.
No action needed — defense in depth is adequate here.
Finding 5 · Advisory vs. hard-fail escalation path is correctly separated ✅
Two distinct checks exist and serve complementary roles:
ci_checks._check_includes_consent(line 339): Alwayspassed=True. Baseline advisory nudge. Cannot block CI on its own.policy_checks._check_includes_explicit(line 575): Gated onpolicy.manifest.require_explicit_includes(schema default:False). When enabled, rejects bothNoneand"auto"— only an explicit path list satisfies it.
The escalation from advisory → hard-fail requires an org-level policy file opt-in (require_explicit_includes: true), parsed in policy/parser.py:218. The two checks are wired to separate runners (run_baseline_checks vs run_dependency_policy_checks), so there is no risk of the advisory accidentally becoming a hard block at the baseline level.
Finding 6 · Hash comparison timing safety — not applicable ✅
The dict(self.local_deployed_file_hashes) != dict(other.local_deployed_file_hashes) at lockfile.py:458 uses Python's standard !=. This is used in is_semantically_equivalent(), which is a write-optimization (avoid rewriting lockfile when nothing changed), not a security gate. The attacker already knows the expected hashes (they're in the lockfile on disk), so there is no secret to leak via timing side-channels. hmac.compare_digest is not needed here.
The actual integrity check in _check_content_integrity (ci_checks.py:297) uses compute_file_hash(file_path) != expected_hash — also not timing-sensitive because the comparison detects drift between on-disk content and a locally-recorded hash, not authentication against a remote secret.
Finding 7 · Bundle lockfile strip has no race condition ✅
lockfile_enrichment.py:163-165 operates on a fresh yaml.safe_load() copy of the serialized lockfile, not the in-memory LockFile object. The to_yaml() call at line 146 already excludes the self-entry via the pop/try/finally pattern. The subsequent data.pop() removes the flat local_deployed_files / local_deployed_file_hashes keys from the deserialized dict copy. No shared mutable state is involved — no race condition is possible.
Summary
| # | Finding | Severity | Action |
|---|---|---|---|
| 1 | includes paths not validated against traversal |
Medium | Add validate_path_segments at parse time |
| 2 | _SELF_KEY private import across modules |
Low | Promote to public constant or expose predicate |
| 3 | get_all_dependencies() call-site audit |
✅ Clean | — |
| 4 | Self-entry repo_url="<self>" fetch risk |
✅ Clean | Fail-closed by path validation |
| 5 | Advisory/hard-fail escalation separation | ✅ Clean | — |
| 6 | Hash comparison timing safety | ✅ Clean | Not a secret comparison |
| 7 | Bundle lockfile strip race condition | ✅ Clean | No shared mutable state |
Finding 1 is the only item I'd request addressed in this PR — it's a cheap parse-time guard that prevents a future vulnerability when includes gains file-operation semantics. Finding 2 is a hardening suggestion that can be addressed separately.
Generated by PR Review Panel for issue #889 · ● 15.1M · ◷
APM Review Panel VerdictDisposition: APPROVE (with two minor follow-up items noted below; neither is a merge blocker) Per-persona findingsPython Architect: This PR closes a real audit gap with a well-reasoned design. The self-entry synthesis, the classDiagram
direction TB
class LockFile {
<<DataStore>>
+dependencies: Dict[str, LockedDependency]
+local_deployed_files: List[str]
+local_deployed_file_hashes: Dict[str, str]
+get_all_dependencies() List
+get_package_dependencies() List
+from_yaml(yaml_str) LockFile
+to_yaml() str
}
note for LockFile "Self-Entry Synthesis:\n'.' key synthesized in from_yaml()\nstripped (pop + finally) in to_yaml()"
class LockedDependency {
<<ValueObject>>
+repo_url: str
+source: str
+local_path: str
+is_dev: bool
+depth: int
+deployed_file_hashes: Dict
}
class APMPackage {
<<DataClass>>
+includes: Optional[Union[str, List[str]]]
+from_apm_yml(path) APMPackage
}
class ManifestPolicy {
<<DataClass>>
+require_explicit_includes: bool
}
class ApmPolicy {
<<DataClass>>
+manifest: ManifestPolicy
}
class CIChecks {
<<Module>>
+run_baseline_checks(root) CIAuditResult
+_check_content_integrity(root, lock) CheckResult
+_check_includes_consent(manifest, lock) CheckResult
}
class PolicyChecks {
<<Module>>
+run_dependency_policy_checks(...) CIAuditResult
+_check_includes_explicit(includes, policy) CheckResult
+_INCLUDES_NOT_PROVIDED: object
}
note for PolicyChecks "Sentinel Object pattern:\n_INCLUDES_NOT_PROVIDED skips\nexplicit-includes check for\nlegacy callers without manifest ctx"
class BaseIntegrator {
<<AbstractBase>>
+validate_deploy_path(rel, root) bool
}
LockFile *-- LockedDependency : contains
CIChecks ..> LockFile : reads
CIChecks ..> APMPackage : reads includes
CIChecks ..> BaseIntegrator : validate_deploy_path guard
PolicyChecks ..> APMPackage : reads includes
PolicyChecks ..> ManifestPolicy : reads require_explicit_includes
ApmPolicy *-- ManifestPolicy
class LockFile:::touched
class APMPackage:::touched
class ManifestPolicy:::touched
class CIChecks:::touched
class PolicyChecks:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["apm audit --ci\ncommands/audit.py"] --> B["run_baseline_checks\npolicy/ci_checks.py"]
B --> C["_check_lockfile_exists\n[FS] reads apm.yml, conditionally reads apm.lock.yaml\nNEW: promotes local-only repos to has_deps=True"]
C --> D{"has_deps or\nlocal_deployed_files?"}
D -- No --> E["[+] no deps, lockfile not required"]
D -- Yes --> F{"lockfile on disk?"}
F -- No --> G["[x] lockfile missing"]
F -- Yes --> H["[FS] LockFile.from_yaml()\nsynthesizes '.' self-entry from local_deployed_files"]
H --> I["_check_ref_consistency"]
I --> J["_check_deployed_files_present\n[FS] checks deployed file paths"]
J --> K["_check_no_orphans\nskips dep_key == _SELF_KEY"]
K --> L["_check_config_consistency"]
L --> M["_check_content_integrity\n[FS] scan_lockfile_packages + compute_file_hash\nNEW: iterates lock.dependencies incl. self-entry\napplies validate_deploy_path guard per rel_path"]
M --> N{"unicode issues\nor hash drift?"}
N -- No --> O["[+] No Unicode or hash drift"]
N -- Yes --> P["[x] N file(s) with hash drift -- run 'apm install'\ndep_label = '<self>' for local content"]
O --> Q["_check_includes_consent (advisory, passed=True always)\nNEW: nudges toward 'includes:' declaration"]
P --> Q
Q --> R["CIAuditResult (7 checks)"]
S["apm install\ninstall/phases/policy_gate.py"] --> T["extra_kwargs = {manifest_includes: apm_package.includes}\nif ctx.apm_package else {}"]
T --> U["run_dependency_policy_checks(...manifest_includes=...)"]
U --> V["_check_includes_explicit\nNEW: skipped if _INCLUDES_NOT_PROVIDED"]
V --> W{"require_explicit_includes?"}
W -- False --> X["[+] skip"]
W -- True --> Y{"includes == None or 'auto'?"}
Y -- Yes --> Z["[x] block install"]
Y -- "No (list)" --> AA["[+] explicit paths OK"]
Design patterns
One inconsistency: CLI Logging Expert: Output paths are correct: all findings flow through Hash drift detail lines ( One concern: The remedy string in DevX UX Expert: The The three-tier mental model (undeclared / CLI reference ( The Supply Chain Security Expert: Integrity improvements are genuine: hash drift detection for locally-authored files closes a real gap. The Three security observations:
No path traversal risks introduced. Packer stripping of Auth Expert: Not activated -- no auth-related files changed; the PR touches OSS Growth Hacker: This PR is a strong enterprise narrative beat: "APM now detects config drift in your team's locally-authored AI primitives." That maps directly to the trust / governance story that CISOs and VPEs care about (per Lorenzo Storelli's AI Controls prototype using APM as substrate). Specific growth angles:
Side-channel to CEO: this is the first feature that makes APM's local content governance machine-verifiable in CI. Worth framing the release note around "trust but verify your team's AI configurations" -- that's a viral angle for security-conscious engineering orgs. The CEO arbitrationSpecialists aligned: this is a well-scoped, well-tested feature that closes a genuine audit gap without introducing new threat surfaces. The thread-safety smell in Two items are worth a follow-up issue rather than blocking merge: the Required actions before merge
Optional follow-ups
|
Rewrite `just ai::apm-sync` around two upstream apm improvements: * microsoft/apm#889 (merged) wires content-integrity hash verification into `apm audit --ci` — every APM-managed file (`.claude/`, `.codex/`, `.agents/`, `.opencode/`, `opencode.json`, MCP configs) is now checked against the lockfile's recorded hashes. The recursive `diff -r` loop that re-installed into scratch and compared each deploy dir is redundant and goes away. * microsoft/apm#888 (`apm install --root`) replaces the rsync-the- worktree-into-scratch dance with a single flag. The scratch staging that remains is purely for the `apm compile` leg — apm's distributed compiler scans the project tree to score AGENTS.md placement, so the scratch needs the source file inventory. It drops to an `rsync -aH` with one set of excludes (down from ~20 lines of setup + 25 lines of diff gates). * Staged on juspay/apm@feat/install-compile-root-flag until #888 lands in microsoft/apm — revert the `apm_cmd` pin once that happens. Net: the recipe trims from 127 lines to 51; all seven audit checks pass, and the per-AGENTS.md diff loop still catches compile-output drift that audit can't (AGENTS.md files aren't lockfile-tracked).
Rewrite `just ai::apm-sync` around two upstream apm improvements: * microsoft/apm#889 (merged) wires content-integrity hash verification into `apm audit --ci` — every APM-managed file (`.claude/`, `.codex/`, `.agents/`, `.opencode/`, `opencode.json`, MCP configs) is now checked against the lockfile's recorded hashes. The recursive `diff -r` loop that re-installed into scratch and compared each deploy dir is redundant and goes away. * microsoft/apm#888 (`apm install --root`) replaces the rsync-the- worktree-into-scratch dance with a single flag. The scratch staging that remains is purely for the `apm compile` leg — apm's distributed compiler scans the project tree to score AGENTS.md placement, so the scratch needs the source file inventory. It drops to an `rsync -aH` with one set of excludes (down from ~20 lines of setup + 25 lines of diff gates). * Staged on juspay/apm@feat/install-compile-root-flag until #888 lands in microsoft/apm — revert the `apm_cmd` pin once that happens. Net: the recipe trims from 127 lines to 51; all seven audit checks pass, and the per-AGENTS.md diff loop still catches compile-output drift that audit can't (AGENTS.md files aren't lockfile-tracked).
What & Why
CISOs reported an audit-blindness gap:
apm installwas deploying local.apm/content into governed directories (.github/,.claude/, etc.), butapm audit --cinever hash-verified those deployed files. The lockfile recordedlocal_deployed_filesand their hashes, yet the audit pipeline only walked package dependencies — so post-install tampering of any locally-sourced file went undetected.This PR closes the gap with end-to-end hash verification of every managed file plus a new
includes:manifest field that gives users (and enterprises) explicit governance consent over what local content gets deployed.Closes #887.
Solution overview
<self>dependency keyed by_SELF_KEY = ".". The on-disk YAML schema is unchanged;to_yaml()does a round-trip pop+restore so the synthetic entry never persists.includes:manifest field — three modes:auto(deploy all.apm/), an explicit list, or omitted (deploy nothing). Purely additive; existing manifests keep working.content-integrityaudit check — now covers the self-entry alongside package deps, detecting post-install tampering on any managed file.includes-consent— advisory only, never blocks.manifest.require_explicit_includes(block/warn/off) — enterprises can mandate explicit include lists.apm init -yscaffoldsincludes: auto— clean first-run UX.Critical bug fixes discovered during dogfood
These were caught while dogfooding the new audit on the APM repo itself and are essential to the fix:
cleanup.py— orphan loop was deleting all 26 deployed files post-install because the synthesized self-entry was misclassified as an orphan package. Fixed by treating_SELF_KEYas protected.lockfile.py—is_semantically_equivalentskippedlocal_deployed_file_hashescomparison, so hash-only updates never persisted. Fixed by including the hashes in the equivalence check.lockfile_enrichment.py(bundle path) — local-content fields needed stripping after the YAML round-trip. Fixed.Audit coverage: before / after
flowchart LR subgraph BEFORE["BEFORE (audit blind to local .apm/)"] I1[apm install] --> S1[.apm/ source files] S1 --> D1[.github/ deployed] I1 --> L1[apm.lock.yaml<br/>local_deployed_files + hashes<br/>written but never read] A1[apm audit --ci] --> L1 A1 -.never reads.-> D1 D1 -.HASH DRIFT INVISIBLE.-> X1((CISO blind)) endflowchart LR subgraph AFTER["AFTER (every managed file verified)"] I2[apm install] --> S2[.apm/ source files] S2 --> D2[.github/ deployed] I2 --> L2[apm.lock.yaml<br/>local_deployed_files + hashes] L2 --> SE[from_yaml synthesizes<br/>self-entry under <self>] A2[apm audit --ci] --> L2 SE --> HV[content-integrity:<br/>hash-verify every<br/>deployed file] HV -. detects .-> D2 HV --> OK([7/7 green]) HV --> DRIFT([hash-drift: file<br/>dep=<self>]) endVerification
5383/5384pass. The sole failure is the pre-existing flakytest_user_scope_skips_workspace_runtimes, unrelated to this change.apm installclean,apm audit --cireturns 7/7 greendep=<self>apm init -yconfirmed to scaffoldincludes: auto.auth-expert.agent.mdandcicd.instructions.mdremains visible — these are real post-install hand-edits from prior PRs and are handled outside this PR.Panel reviews
All three specialist panels reviewed; CEO signed off SHIP.
initscaffold, doc check counts updated (6+16 -> 7+17), error wording branched,dep=<self>label introduced,cli-commands.mdupdated.Breaking changes
None. Purely additive. No migration required for any user class — existing manifests, lockfiles, and CI flows continue to work unchanged.
Version
Bumps to 0.10.0 (minor: significant new functionality, no breakage).
Test plan
uv run pytest tests/unit tests/test_console.py uv run pytest tests/integration/test_local_content_audit.py # new file: 5 subprocess tests A-E