Uh oh!
There was an error while loading. Please reload this page.
feat(audit): close audit-blindness gap for local .apm/ content (#887) - #889
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
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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>
Daniel Meppiel (danielmeppiel)
commented
Apr 24, 2026
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
Daniel Meppiel (danielmeppiel)
commented
Apr 24, 2026
Found issue: shared/apm.md restore step crashes on PRs that modify |
Daniel Meppiel (danielmeppiel)
commented
Apr 24, 2026
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 |
Daniel Meppiel (danielmeppiel)
commented
Apr 24, 2026
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>
Daniel Meppiel (danielmeppiel)
commented
Apr 24, 2026
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
|
Uh oh!
There was an error while loading. Please reload this page.
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).
* feat(drift): Phase A infra - guards + diagnostic category - Add _ReadOnlyProjectGuard context manager (utils/guards.py): snapshots stat of protected paths, raises ProtectedPathMutationError on any mutation. Defense-in-depth above the scratch-root remap. - Add CATEGORY_DRIFT + drift() recording method to DiagnosticCollector. - Add drift_count property and _render_drift_group renderer that groups by kind (modified/unintegrated/orphaned) with stable section header for machine consumers. - Tests: 7 unit tests covering happy path, mutation, creation, deletion, missing-tolerated, exception-not-masked, single-file protected path. Refs #1071. Phase A of WIP/drift/06-final-plan.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(drift): Phase B+C - replay engine + audit CLI wiring Implements the drift detection feature per WIP/drift/06-final-plan.md (closes#1071 scope alignment with #898). Engine (Phase B): - src/apm_cli/install/drift.py: ReplayConfig, DriftFinding, CheckLogger, CacheMissError, normalization helpers (build-id strip, line endings, BOM), run_replay() (cache-only), diff_scratch_against_project(), text/json/sarif renderers, atexit scratch cleanup. - src/apm_cli/install/services.py: scratch_root kwarg with ensure_path_within defense-in-depth guard for replay isolation. - src/apm_cli/policy/ci_checks.py: _check_drift() wrapper returning (CheckResult, list[DriftFinding]); graceful CacheMissError handling. CLI surface (Phase C): - src/apm_cli/commands/audit.py: --no-drift opt-out flag with mutex against --strip/--file via UsageError. Drift wired into both _audit_ci_gate (--ci) and _audit_content_scan (bare project audit) paths, default-on per ADR-02. JSON/SARIF/text renderers integrated; --no-drift warning gated to text mode (stdout cleanliness). Tests: - tests/unit/install/test_drift.py: 13 unit tests (normalization, diff cases, renderers). - Legacy --ci tests opt out of drift via batch --no-drift injection (fixture parity, not a behavior change). 7597 unit tests pass; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(drift): Phase D - integration + e2e + perf coverage (43 tests) Implements the locked test matrix for issue #1071 drift detection. Floor of 43 tests across three new files closes the 'ULTRA HARDENING OF HELL' coverage requirement. New files: - tests/integration/test_drift_check.py (32 tests): * Section A: 9 drift cases (modified/unintegrated/orphaned + CRLF/ BOM/Build-ID false-positive guards) * Section B: 4 past-PR regressions (#1067, #882, #889, source-deleted) * Section C: 7 edges (no/corrupt lockfile, untracked governed, no-write contract, idempotency) * Section D: 3 multi-target (copilot/claude/cursor) * Section E: 9 default-on / --no-drift opt-out (mutex, stderr routing, JSON suppression) - tests/integration/test_drift_check_e2e.py (10 tests): full install->mutate->audit loop with mix_stderr=False, air-gap proof, JSON/SARIF stability, 30s smoke - tests/unit/install/test_drift_perf.py (1 test): 100 primitives replay+diff under 5s Engine fix surfaced by tests: - src/apm_cli/install/drift.py: run_replay now reads apm.yml's target field via parse_target_field and passes it to resolve_targets. Without this, multi-target projects (copilot+claude+cursor) replayed only the auto-detected primary target, falsely reporting secondary target deployments as orphaned. Helper _read_apm_yml_target() added. CI wiring: - scripts/test-integration.sh: two new blocks in run_e2e_tests() invoking the integration + e2e suites before the final success log. Both safe to run without GITHUB_APM_PAT (cache-only, mocked network). Verification: 56 drift-domain tests pass; full repo lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(drift): CHANGELOG + Starlight guide + apm-usage skill + ci.yml note - CHANGELOG.md: Added [Unreleased] entry under Added describing the default-on drift detection in apm audit, the three failure modes it catches, false-positive guards, --no-drift opt-out + mutex semantics, and the JSON/SARIF integration shape. Closes#1071, supersedes #898. - docs/src/content/docs/guides/drift-detection.md (NEW, sidebar order 7): Full user-facing guide -- what drift means, how the cache-only replay works (with mermaid diagram), exit-code matrix, when to use --no-drift, output formats, and the CI single-line gate that replaces the legacy git status --porcelain script. - packages/apm-guide/.apm/skills/apm-usage/commands.md: Extended the audit row with --no-drift flag and added a paragraph documenting the drift-by-default behavior, three failure modes, false-positive normalization, and JSON/SARIF integration. Aligns the skill that ships in apm-guide with the new CLI surface (per apm-keep-docs-up-to-date.instructions.md rule 4). - .github/workflows/ci.yml: Annotated Gate B (legacy bash drift check) with a comment marking it redundant once apm-action ships a CLI with default-on drift detection (this PR's release). Kept as defense-in-depth fallback until then. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(drift): address panel feedback - recovery hint + doc-sync CEO panel recommended landing two in-PR follow-ups before merge: 1. Recovery hint in drift output (cli-logging + devx-ux convergence): render_drift_text now appends '[i] Run apm install to re-sync deployed files with the lockfile.' so users see WHAT and HOW in one message. Honors Message Writing Rule #4 'Include the fix'. 2. Doc-sync (doc-writer + devx-ux convergence): - reference/cli-commands.md: add --no-drift to audit options table; amend --ci description to mention drift contribution. - integrations/ci-cd.md: replace bash 'git status --porcelain' workaround under 'Verify Deployed Primitives' with 'apm audit --ci' one-liner; update 'We dogfood this' callout text. - getting-started/quick-start.md: retarget stale cross-ref from the now-superseded ci-cd anchor to the new drift-detection guide. - guides/drift-detection.md: drop the self-contradictory case #2 in 'When to use --no-drift' (strip-mode is auto-skipped, not opt-out). - CHANGELOG.md: compress verbose entry to one Keep-a-Changelog line pointing readers to the guide for detail. Tracked as follow-up issues (CEO call): - supply-chain: verify cache content matches lockfile resolved_commit before drift replay trusts it (commit-SHA pinning bypass on shared CI caches). - test-coverage: inverse-normalization unit test asserting BOM/CRLF/ Build-ID guards do NOT mask real content drift (safety invariant). Lint clean. 45 drift tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(drift): address Copilot review - exit-code contract + types + diagnostics Bare 'apm audit' is advisory (exit 0 on drift); 'apm audit --ci' is the gate (exit 1). Closes the regression introduced when content-scan escalation accidentally also escalated drift findings. Also addresses inline review: - A2: vacuous ASCII-encoding assertion now scopes per-line - A4: tuple[float, int] -> tuple[int, int] in guards.py - A5: type-annotated _check_drift signature - A6: clarified DRIFT_ORPHANED comment - A7: CHANGELOG references PR + closes - A3: CacheMiss message now drift-specific (no --no-cache confusion) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(drift): link drift detection guide from README security section Per oss-growth: surfaces drift detection alongside content security and lockfile integrity in the conversion-critical Production-grade section, so a reader scanning for 'why APM' sees the supply-chain story end-to-end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(drift): cache pin marker for stale-cache detection apm install drops a .apm-pin JSON marker into each cached package root recording the resolved_commit; apm audit verifies it before running drift replay. Catches the 'teammate bumped lockfile, did not reinstall' + 'shared CI runner reused stale apm_modules' scenarios that would otherwise silently produce misleading drift output. LockfileBuilder syncs markers UNCONDITIONALLY (even when the lockfile YAML is unchanged and even when no install happens), so existing users self-heal on their next 'apm install'. This is stale-cache detection, NOT cryptographic integrity -- defending against active cache tampering requires content-addressed hashes, which is deferred. Schema (v1): {schema_version: 1, resolved_commit: <sha>} Marker file: <install_path>/.apm-pin Coverage: - 14 unit tests in test_cache_pin.py (positive + every error path + skip rules + idempotent re-run + self-heal regression) - 1 integration test in test_drift_check_e2e.py exercising the full install -> mark -> verify flow against a synthetic cache Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address panel follow-ups C1-C5 on PR #1137 C1 (supply-chain): Fail closed on unpinned remote deps - cache_pin.find_unpinned_remote_deps() helper + stderr warning in sync_markers_for_lockfile - drift._materialize_install_path raises CacheMissError for remote deps with resolved_commit=None (was silent fail-open) - Replaced silent-skip test with warning assertion + new helper test C2 (architecture): Wire _ReadOnlyProjectGuard into run_replay - run_replay() now wraps the deps loop with _ReadOnlyProjectGuard on governed root dirs + apm.lock.yaml + AGENTS.md - Regression test: monkeypatched leaky integrator triggers ProtectedPathMutationError C3 (cli-logging-ux): Stderr message on swallowed CacheMissError - audit._audit_content_scan emits '[!] drift check could not run: <msg>' to stderr when drift_failed and no findings (covers cache miss, missing lockfile, cache-pin error) - Integration test e10 asserts stderr message in bare-audit path C4 (docs): Baseline-check phrasing + CHANGELOG link - governance-guide, ci-cd, cli-commands now read '7 baseline checks plus integration drift detection' - CHANGELOG drift-detection link points to docs site URL C5 (oss-growth): User-promise framing - CHANGELOG drift entry leads with the user promise (forgotten installs + hand-edits) before mechanism - drift-detection.md gains a 'Try it now' block at the top - Before/after CI comparison promoted to its own subsection with explicit framing of what the bash workaround missed Verification: ruff check + format silent; 7621 unit tests + 27 drift integration tests green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): trim drift entry to single 'so what?' line Collapse the two added entries (drift + cache-pin markers) into one short line that answers the developer 'so what?' and points to the Drift Detection guide for the full mechanism + opt-out + cache-pin details. Per maintainer feedback: the previous entries were too long for a CHANGELOG. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
…#889) * feat(audit): close audit-blindness gap for local .apm/ content (#887) 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> * test(audit): scanner coverage + packer regression + integration round-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> * feat(audit): wire includes:auto + fix self-entry regressions, docs (#887) 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> * feat(audit): apply panel-review fixes for #887 (sec/devx/logging) 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> * Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(audit): address review comments for #889 Agent-Logs-Url: https://github.com/microsoft/apm/sessions/fb9d2cd4-ba19-4ecb-b6cd-743cd5743bdd Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* feat(drift): Phase A infra - guards + diagnostic category - Add _ReadOnlyProjectGuard context manager (utils/guards.py): snapshots stat of protected paths, raises ProtectedPathMutationError on any mutation. Defense-in-depth above the scratch-root remap. - Add CATEGORY_DRIFT + drift() recording method to DiagnosticCollector. - Add drift_count property and _render_drift_group renderer that groups by kind (modified/unintegrated/orphaned) with stable section header for machine consumers. - Tests: 7 unit tests covering happy path, mutation, creation, deletion, missing-tolerated, exception-not-masked, single-file protected path. Refs #1071. Phase A of WIP/drift/06-final-plan.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(drift): Phase B+C - replay engine + audit CLI wiring Implements the drift detection feature per WIP/drift/06-final-plan.md (closes#1071 scope alignment with #898). Engine (Phase B): - src/apm_cli/install/drift.py: ReplayConfig, DriftFinding, CheckLogger, CacheMissError, normalization helpers (build-id strip, line endings, BOM), run_replay() (cache-only), diff_scratch_against_project(), text/json/sarif renderers, atexit scratch cleanup. - src/apm_cli/install/services.py: scratch_root kwarg with ensure_path_within defense-in-depth guard for replay isolation. - src/apm_cli/policy/ci_checks.py: _check_drift() wrapper returning (CheckResult, list[DriftFinding]); graceful CacheMissError handling. CLI surface (Phase C): - src/apm_cli/commands/audit.py: --no-drift opt-out flag with mutex against --strip/--file via UsageError. Drift wired into both _audit_ci_gate (--ci) and _audit_content_scan (bare project audit) paths, default-on per ADR-02. JSON/SARIF/text renderers integrated; --no-drift warning gated to text mode (stdout cleanliness). Tests: - tests/unit/install/test_drift.py: 13 unit tests (normalization, diff cases, renderers). - Legacy --ci tests opt out of drift via batch --no-drift injection (fixture parity, not a behavior change). 7597 unit tests pass; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(drift): Phase D - integration + e2e + perf coverage (43 tests) Implements the locked test matrix for issue #1071 drift detection. Floor of 43 tests across three new files closes the 'ULTRA HARDENING OF HELL' coverage requirement. New files: - tests/integration/test_drift_check.py (32 tests): * Section A: 9 drift cases (modified/unintegrated/orphaned + CRLF/ BOM/Build-ID false-positive guards) * Section B: 4 past-PR regressions (#1067, #882, #889, source-deleted) * Section C: 7 edges (no/corrupt lockfile, untracked governed, no-write contract, idempotency) * Section D: 3 multi-target (copilot/claude/cursor) * Section E: 9 default-on / --no-drift opt-out (mutex, stderr routing, JSON suppression) - tests/integration/test_drift_check_e2e.py (10 tests): full install->mutate->audit loop with mix_stderr=False, air-gap proof, JSON/SARIF stability, 30s smoke - tests/unit/install/test_drift_perf.py (1 test): 100 primitives replay+diff under 5s Engine fix surfaced by tests: - src/apm_cli/install/drift.py: run_replay now reads apm.yml's target field via parse_target_field and passes it to resolve_targets. Without this, multi-target projects (copilot+claude+cursor) replayed only the auto-detected primary target, falsely reporting secondary target deployments as orphaned. Helper _read_apm_yml_target() added. CI wiring: - scripts/test-integration.sh: two new blocks in run_e2e_tests() invoking the integration + e2e suites before the final success log. Both safe to run without GITHUB_APM_PAT (cache-only, mocked network). Verification: 56 drift-domain tests pass; full repo lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(drift): CHANGELOG + Starlight guide + apm-usage skill + ci.yml note - CHANGELOG.md: Added [Unreleased] entry under Added describing the default-on drift detection in apm audit, the three failure modes it catches, false-positive guards, --no-drift opt-out + mutex semantics, and the JSON/SARIF integration shape. Closes#1071, supersedes #898. - docs/src/content/docs/guides/drift-detection.md (NEW, sidebar order 7): Full user-facing guide -- what drift means, how the cache-only replay works (with mermaid diagram), exit-code matrix, when to use --no-drift, output formats, and the CI single-line gate that replaces the legacy git status --porcelain script. - packages/apm-guide/.apm/skills/apm-usage/commands.md: Extended the audit row with --no-drift flag and added a paragraph documenting the drift-by-default behavior, three failure modes, false-positive normalization, and JSON/SARIF integration. Aligns the skill that ships in apm-guide with the new CLI surface (per apm-keep-docs-up-to-date.instructions.md rule 4). - .github/workflows/ci.yml: Annotated Gate B (legacy bash drift check) with a comment marking it redundant once apm-action ships a CLI with default-on drift detection (this PR's release). Kept as defense-in-depth fallback until then. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(drift): address panel feedback - recovery hint + doc-sync CEO panel recommended landing two in-PR follow-ups before merge: 1. Recovery hint in drift output (cli-logging + devx-ux convergence): render_drift_text now appends '[i] Run apm install to re-sync deployed files with the lockfile.' so users see WHAT and HOW in one message. Honors Message Writing Rule #4 'Include the fix'. 2. Doc-sync (doc-writer + devx-ux convergence): - reference/cli-commands.md: add --no-drift to audit options table; amend --ci description to mention drift contribution. - integrations/ci-cd.md: replace bash 'git status --porcelain' workaround under 'Verify Deployed Primitives' with 'apm audit --ci' one-liner; update 'We dogfood this' callout text. - getting-started/quick-start.md: retarget stale cross-ref from the now-superseded ci-cd anchor to the new drift-detection guide. - guides/drift-detection.md: drop the self-contradictory case #2 in 'When to use --no-drift' (strip-mode is auto-skipped, not opt-out). - CHANGELOG.md: compress verbose entry to one Keep-a-Changelog line pointing readers to the guide for detail. Tracked as follow-up issues (CEO call): - supply-chain: verify cache content matches lockfile resolved_commit before drift replay trusts it (commit-SHA pinning bypass on shared CI caches). - test-coverage: inverse-normalization unit test asserting BOM/CRLF/ Build-ID guards do NOT mask real content drift (safety invariant). Lint clean. 45 drift tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(drift): address Copilot review - exit-code contract + types + diagnostics Bare 'apm audit' is advisory (exit 0 on drift); 'apm audit --ci' is the gate (exit 1). Closes the regression introduced when content-scan escalation accidentally also escalated drift findings. Also addresses inline review: - A2: vacuous ASCII-encoding assertion now scopes per-line - A4: tuple[float, int] -> tuple[int, int] in guards.py - A5: type-annotated _check_drift signature - A6: clarified DRIFT_ORPHANED comment - A7: CHANGELOG references PR + closes - A3: CacheMiss message now drift-specific (no --no-cache confusion) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(drift): link drift detection guide from README security section Per oss-growth: surfaces drift detection alongside content security and lockfile integrity in the conversion-critical Production-grade section, so a reader scanning for 'why APM' sees the supply-chain story end-to-end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(drift): cache pin marker for stale-cache detection apm install drops a .apm-pin JSON marker into each cached package root recording the resolved_commit; apm audit verifies it before running drift replay. Catches the 'teammate bumped lockfile, did not reinstall' + 'shared CI runner reused stale apm_modules' scenarios that would otherwise silently produce misleading drift output. LockfileBuilder syncs markers UNCONDITIONALLY (even when the lockfile YAML is unchanged and even when no install happens), so existing users self-heal on their next 'apm install'. This is stale-cache detection, NOT cryptographic integrity -- defending against active cache tampering requires content-addressed hashes, which is deferred. Schema (v1): {schema_version: 1, resolved_commit: <sha>} Marker file: <install_path>/.apm-pin Coverage: - 14 unit tests in test_cache_pin.py (positive + every error path + skip rules + idempotent re-run + self-heal regression) - 1 integration test in test_drift_check_e2e.py exercising the full install -> mark -> verify flow against a synthetic cache Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address panel follow-ups C1-C5 on PR #1137 C1 (supply-chain): Fail closed on unpinned remote deps - cache_pin.find_unpinned_remote_deps() helper + stderr warning in sync_markers_for_lockfile - drift._materialize_install_path raises CacheMissError for remote deps with resolved_commit=None (was silent fail-open) - Replaced silent-skip test with warning assertion + new helper test C2 (architecture): Wire _ReadOnlyProjectGuard into run_replay - run_replay() now wraps the deps loop with _ReadOnlyProjectGuard on governed root dirs + apm.lock.yaml + AGENTS.md - Regression test: monkeypatched leaky integrator triggers ProtectedPathMutationError C3 (cli-logging-ux): Stderr message on swallowed CacheMissError - audit._audit_content_scan emits '[!] drift check could not run: <msg>' to stderr when drift_failed and no findings (covers cache miss, missing lockfile, cache-pin error) - Integration test e10 asserts stderr message in bare-audit path C4 (docs): Baseline-check phrasing + CHANGELOG link - governance-guide, ci-cd, cli-commands now read '7 baseline checks plus integration drift detection' - CHANGELOG drift-detection link points to docs site URL C5 (oss-growth): User-promise framing - CHANGELOG drift entry leads with the user promise (forgotten installs + hand-edits) before mechanism - drift-detection.md gains a 'Try it now' block at the top - Before/after CI comparison promoted to its own subsection with explicit framing of what the bash workaround missed Verification: ruff check + format silent; 7621 unit tests + 27 drift integration tests green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): trim drift entry to single 'so what?' line Collapse the two added entries (drift + cache-pin markers) into one short line that answers the developer 'so what?' and points to the Drift Detection guide for the full mechanism + opt-out + cache-pin details. Per maintainer feedback: the previous entries were too long for a CHANGELOG. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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
Verification
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