Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `apm audit` drift check now returns skip-with-info (`passed=True`) when the install cache is cold, instead of failing the audit; bare `apm audit` surfaces the skip reason on stderr so CI pipelines that have not yet run `apm install` are not incorrectly red-marked. (#1289)
- `extends: org` now correctly layers `dependencies.require` and `dependencies.deny` from the parent policy when the child omits the `dependencies:` block entirely; `None` signals "no opinion" (transparent) while `[]` signals explicit override. (#1290)
- CI self-check job now uses `setup-only: true` + `apm audit --ci --no-drift` so managed files are not overwritten by `apm install` before `content-integrity` runs; documented the audit-only CI pattern and the install-before-audit blind spot in the enterprise and CI/CD guides. (#1291)
- Pin `Path.home()` under unit tests via a session-scoped autouse conftest fixture, fixing 56 Windows runner failures on the new `windows-2025-vs2026` GitHub-hosted image where `USERPROFILE`/`HOMEDRIVE`+`HOMEPATH` are not seeded for pytest workers; also patch the `_check_and_notify_updates` import binding in the disabled-self-update test so it no longer races on the version-check cache. (#1270)
Expand Down
9 changes: 6 additions & 3 deletions docs/src/content/docs/enterprise/drift-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ lockfile-exists -> ref-consistency -> deployed-files-present
After the baseline passes, it replays the install in a scratch directory
from the cache and diffs against the working tree to surface
`unintegrated`, `modified`, and `orphaned` files. Pass `--no-drift` to
skip the replay. With `--policy <source>` it also evaluates the
discovered policy against the lockfile. Source:
`src/apm_cli/commands/audit.py`, `src/apm_cli/policy/ci_checks.py`.
skip the replay. When the install cache has not been warmed yet (fresh
checkout before the first `apm install`), the drift check is skipped
with an informational message rather than failing; run `apm install` to
warm the cache and enable the check on the next run. With `--policy
<source>` it also evaluates the discovered policy against the lockfile.
Source: `src/apm_cli/commands/audit.py`, `src/apm_cli/policy/ci_checks.py`.
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

### `apm audit` (default)

Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/reference/baseline-checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,10 @@ the [policy schema](../policy-schema/).
### `drift`

- **What it verifies.** That the working tree matches what an install from the current lockfile would produce. The check replays the install pipeline into a scratch tree and diffs the result against the project.
- **Fails when.** Any deployed file differs from the replay output (hand-edits, missing integrations, orphaned files), or the cache is cold and `apm install` has not populated `apm_modules` (the replay is cache-only by design so audit stays deterministic).
- **Fails when.** Any deployed file differs from the replay output (hand-edits, missing integrations, orphaned files).
- **Skips when.** The install cache has not been warmed (the replay is cache-only by design so audit stays deterministic). The check returns a pass with an informational message advising the user to run `apm install` first.
- **Skip with.** `apm audit --ci --no-drift` (reduces coverage; reserve for performance-constrained CI loops).
- **Remediation.** Run `apm install` to restore the deployed state, or revert the hand-edit. For a cache-miss failure, the same `apm install` warms the cache.
- **Remediation.** Run `apm install` to restore the deployed state, or revert the hand-edit. For a cache-miss skip, the same `apm install` warms the cache and enables the check on the next run.

## Run order and fail-fast

Expand Down
2 changes: 1 addition & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ If no `--target`, no `targets:` in `apm.yml`, and no harness signal is present,
|---------|---------|-----------|
| `apm audit [PKG]` | Scan for security issues + detect integration drift | `--file PATH`, `--strip`, `--dry-run`, `-v`, `-f [text\|json\|sarif\|md]`, `-o PATH`, `--ci`, `--policy SOURCE`, `--no-cache`, `--no-fail-fast`, `--no-drift` |

`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` cache-only into a temporary scratch tree and diffs the result against your working tree. Catches three failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed. The scan is read-only -- never writes to your project, lockfile, or `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives. Use `--no-drift` to opt out (e.g. fast inner loops); the flag is mutually exclusive with `--strip`/`--file`. In `--ci` mode drift findings produce exit code 1 alongside the seven baseline lockfile checks. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/<kind>` where kind is `modified`/`unintegrated`/`orphaned`).
`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` cache-only into a temporary scratch tree and diffs the result against your working tree. Catches three failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed. The scan is read-only -- never writes to your project, lockfile, or `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives. If the install cache has not been warmed (e.g. a fresh checkout before the first `apm install`), the drift check is skipped with an informational message rather than failing; run `apm install` to warm the cache and enable the check on the next run. Use `--no-drift` to opt out (e.g. fast inner loops); the flag is mutually exclusive with `--strip`/`--file`. In `--ci` mode drift findings produce exit code 1 alongside the seven baseline lockfile checks. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/<kind>` where kind is `modified`/`unintegrated`/`orphaned`).

## Distribution

Expand Down
20 changes: 14 additions & 6 deletions src/apm_cli/commands/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ def _audit_content_scan(
and not package
and (project_root / "apm.yml").exists()
):
from ..policy.ci_checks import _check_drift
from ..policy.ci_checks import DRIFT_SKIP_PREFIX, _check_drift

lockfile_path = get_lockfile_path(project_root)
if lockfile_path.exists():
Expand All @@ -710,17 +710,25 @@ def _audit_content_scan(
drift_failed = not drift_check.passed
# Bare `apm audit` is advisory: drift_failed does not gate
# the exit code (that lives in --ci). But silence on a
# cache-pin / cache-miss failure is a UX trap: the user
# cannot tell whether drift was clean or whether it was
# never attempted. Surface the failure reason on stderr
# whenever the drift check failed without producing
# findings (CacheMissError, CachePinError, missing lockfile).
# cache-pin / cache-miss skip or failure is a UX trap: the
# user cannot tell whether drift was clean or whether it was
# never attempted. Surface the reason on stderr whenever the
# drift check produced no findings.
if drift_failed and not drift_findings:
click.echo(
f"{STATUS_SYMBOLS['warning']} drift check could not run: "
f"{drift_check.message}",
err=True,
)
elif (
drift_check.passed
and not drift_findings
and drift_check.message.startswith(DRIFT_SKIP_PREFIX)
):
click.echo(
f"{STATUS_SYMBOLS['warning']} {drift_check.message}",
err=True,
)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
elif no_drift and cfg.output_format == "text":
# In structured output (json/sarif), --no-drift is implicit from
# the absence of the drift check entry; no need to pollute output.
Expand Down
20 changes: 15 additions & 5 deletions src/apm_cli/policy/ci_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,12 @@ def _check_includes_consent(
)


#: Prefix used in the drift :class:`CheckResult` message when the check is
#: skipped due to a cold cache. ``audit.py`` imports this to detect the
#: skip case without comparing against a raw string literal.
DRIFT_SKIP_PREFIX = "drift skipped"


def _check_drift(
project_root: Path,
lockfile: LockFile,
Expand All @@ -421,8 +427,11 @@ def _check_drift(
output format of their choice (text/json/sarif) without re-running
the replay.

Cache-only by default: a missing cache entry produces a check
failure rather than a network fetch (audit must be deterministic).
Cache-only by default: a missing cache entry skips the check with
an informational message rather than failing it. Drift can only
run once the local cache has been warmed by ``apm install``; until
then the audit remains non-blocking so CI does not red-mark a
fresh checkout that has never installed.
"""
from ..deps.lockfile import get_lockfile_path
from ..install.drift import (
Expand All @@ -444,13 +453,14 @@ def _check_drift(

try:
scratch = run_replay(config, logger)
except CacheMissError as exc:
except CacheMissError:
return (
CheckResult(
name="drift",
passed=False,
passed=True,
message=(
f"drift replay aborted: {exc}; run 'apm install' to refresh apm_modules cache"
f"{DRIFT_SKIP_PREFIX}: install cache not populated "
"(run 'apm install' first or pass --no-drift)"
),
),
[],
Expand Down
13 changes: 7 additions & 6 deletions tests/integration/test_drift_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,8 +725,8 @@ def test_e9_text_mode_drift_summary_includes_kind_and_path(
def test_e10_bare_audit_surfaces_cache_miss_on_stderr(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When drift fails (CacheMissError, CachePinError) and produces no
findings, bare ``apm audit`` MUST tell the user on stderr -- silence
"""When drift encounters a CacheMissError it skips with an informational
message and bare ``apm audit`` MUST tell the user on stderr -- silence
is a UX trap (user cannot tell "drift was clean" from "drift never
ran"). Per dev-ux + cli-logging-ux panel feedback (PR #1137)."""
from apm_cli.install.drift import CacheMissError
Expand All @@ -744,8 +744,9 @@ def _boom(*_args, **_kwargs):
monkeypatch.setattr("apm_cli.install.drift.run_replay", _boom)

result = _audit(project, monkeypatch)
# Bare audit is advisory: exit code is not gated on drift failure.
# Bare audit is advisory: exit code is not gated on drift skip.
assert result.exit_code in {0, 2}
# The stderr-warning contract.
assert "drift check could not run" in result.stderr.lower()
assert "cache miss" in result.stderr.lower()
# The stderr-warning contract: user must see that drift was skipped
# and why (cache not yet populated).
assert "drift skipped" in result.stderr.lower()
assert "cache not populated" in result.stderr.lower()
112 changes: 112 additions & 0 deletions tests/unit/policy/test_ci_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,118 @@ def test_remediation_hint_present_in_error_message(self, tmp_path: Path) -> None
assert "fix the YAML syntax error in apm.yml and re-run" in parse_check.message


# -- Group 5: _check_drift cache-miss skip behavior ----------------


class TestCheckDriftCacheMiss:
"""_check_drift must return passed=True (skip-with-info) on CacheMissError.

A cache miss means the user has not yet run ``apm install`` -- failing
the check in that situation would block every fresh checkout and is
unhelpful. The drift check skips with a clear informational message
instead of marking the audit as failed.
"""

def test_cache_miss_returns_passed_true(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""CacheMissError must produce passed=True, not a failure."""
from apm_cli.deps.lockfile import LockFile
from apm_cli.install.drift import CacheMissError
from apm_cli.policy.ci_checks import _check_drift

_write_lockfile(
tmp_path,
textwrap.dedent("""\
lockfile_version: '1'
generated_at: '2025-01-01T00:00:00Z'
dependencies: []
"""),
)
lockfile = LockFile.read(tmp_path / "apm.lock.yaml")
assert lockfile is not None

def _raise_cache_miss(*_args: object, **_kwargs: object) -> None:
raise CacheMissError("org/foo@deadbeef: no cache entry found")

monkeypatch.setattr("apm_cli.install.drift.run_replay", _raise_cache_miss)

check_result, findings = _check_drift(tmp_path, lockfile)

assert check_result.passed, "cache miss must not fail the drift check"
assert findings == [], "no findings expected on cache miss"

def test_cache_miss_message_indicates_skip(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The skip message must guide the user to run 'apm install'."""
from apm_cli.deps.lockfile import LockFile
from apm_cli.install.drift import CacheMissError
from apm_cli.policy.ci_checks import _check_drift

_write_lockfile(
tmp_path,
textwrap.dedent("""\
lockfile_version: '1'
generated_at: '2025-01-01T00:00:00Z'
dependencies: []
"""),
)
lockfile = LockFile.read(tmp_path / "apm.lock.yaml")
assert lockfile is not None

def _raise_cache_miss(*_args: object, **_kwargs: object) -> None:
raise CacheMissError("org/foo@deadbeef: no cache entry found")

monkeypatch.setattr("apm_cli.install.drift.run_replay", _raise_cache_miss)

check_result, _ = _check_drift(tmp_path, lockfile)

assert check_result.name == "drift"
assert "skipped" in check_result.message.lower()
assert "apm install" in check_result.message

def test_cache_miss_does_not_block_other_checks(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Adding the cache-miss drift result to a CIAuditResult must not
cause the aggregate to fail -- passed=True from the skip must
propagate correctly when combined with other passing checks."""
from apm_cli.deps.lockfile import LockFile
from apm_cli.install.drift import CacheMissError
from apm_cli.policy.ci_checks import _check_drift

_write_lockfile(
tmp_path,
textwrap.dedent(""" lockfile_version: '1'
generated_at: '2025-01-01T00:00:00Z'
dependencies: []
"""),
)
lockfile = LockFile.read(tmp_path / "apm.lock.yaml")
assert lockfile is not None

def _raise_cache_miss(*_args: object, **_kwargs: object) -> None:
raise CacheMissError("cold cache")

monkeypatch.setattr("apm_cli.install.drift.run_replay", _raise_cache_miss)

drift_result, findings = _check_drift(tmp_path, lockfile)

# Simulate a CIAuditResult that already has passing baseline checks
# plus the drift skip result; the aggregate must remain passing.
from apm_cli.policy.models import CIAuditResult

aggregate = CIAuditResult()
aggregate.checks.append(CheckResult(name="lockfile-exists", passed=True, message="ok"))
aggregate.checks.append(CheckResult(name="ref-consistency", passed=True, message="ok"))
aggregate.checks.append(drift_result)

assert drift_result.passed, "cache miss must produce a passing drift result"
assert findings == [], "cache miss must produce no findings"
assert aggregate.passed, "cache-miss skip must not fail the aggregate CIAuditResult"


class TestManifestMissingWarning:
"""Tests for the manifest-missing warning when apm.yml is absent."""

Expand Down
Loading