Skip to content

Artifact Guard

Status: Implemented. The base guard (staged, tracked, and untracked scanning across all modes) is implemented, and the issue #928 ignored-present narrowing documented here — restricting ignored-present scans to the worktree and all modes via ArtifactGuardMode::scans_ignored_present() — is now in effect. pre-commit and pre-publish no longer block on ignored-present artifacts. See Worked example for the behavior.

Artifact Guard prevents generated, runtime, cache, and build artifacts from leaking into the parent repository worktree during agent and plugin workflows. It is a blocking safety gate for broad staging, pre-commit, and publication paths. It reports violations with remediation guidance and never deletes, moves, unstages, or rewrites files.

Contents

Behavior

Artifact Guard:

  1. Scan repo-relative paths only.
  2. Check staged paths before commit.
  3. Check tracked and untracked paths before broad staging and workflow publication.
  4. Check ignored-present artifact paths only in the worktree and all modes, which are local-hygiene scans. The commit- and publication-gate modes (pre-commit, pre-publish) never block on ignored-present artifacts.
  5. Fail closed on invalid configuration, invalid paths, Git failures, and unsafe allowlist entries.
  6. Print actionable remediation before exiting.
  7. Leave the repository unchanged.

Ignored-present scope

An ignored-present artifact is a path that exists in the worktree, is untracked, and is excluded by .gitignore (for example an untracked .pytest_cache/ or node_modules/ directory). Because Git will never stage, commit, or publish an ignored path, such a path cannot leak through a commit or a publication step. The guard therefore treats ignored-present artifacts as a local-hygiene concern, not a commit/publish-gate concern:

Mode Purpose Scans ignored-present?
pre-commit Block a commit No
pre-publish Block a publication / broad-staging gate No
staged Inspect the index only No
worktree Local leftover hygiene Yes
all Manual full safety scan Yes

This is a deliberate fail-open narrowing for a class of paths that are outside the guard's threat model. Anything that could actually be committed or published — staged, tracked, or untracked-but-not-ignored paths — is still blocked fail-closed in every gate mode. In particular, an untracked cache directory that is not covered by .gitignore is still a violation under pre-commit and pre-publish, because it could be swept into a git add -A.

Before this narrowing, a gitignored, untracked .pytest_cache/ or node_modules/ directory left behind by a normal test or install run caused amplihack hygiene artifact-guard --mode pre-publish (and --mode pre-commit) to fail closed at the end of a workflow, even though those directories could never be committed or published. See issue #928.

.gitignore reduces Git noise, and it is not a general authorization mechanism: the worktree and all modes still report ignored-present dependency trees, runtime directories, or cache directories so you can clean up local parent-worktree pollution. The commit and publication gates simply do not treat that unreachable pollution as a blocking condition.

target/ is intentionally special-cased. Normal Rust commands create an ignored target/ directory in the repository, and the guard must not make ordinary cargo test, cargo clippy, or pre-commit usage hostile. By default:

target/ source Result
Staged Violation
Tracked Violation
Untracked because it is not ignored Violation
Ignored-present only Not a violation

Workflow steps that need strict build-output isolation should set CARGO_TARGET_DIR to an isolated location instead of relying on the default repo-local target/ directory.

Command-line interface

amplihack hygiene artifact-guard \
  --repo <path> \
  --mode <mode> \
  [--allowlist <path>]
Option Description Default
--repo <path> Repository worktree to scan. The path must resolve inside a Git repository. current directory
--mode <mode> Scan mode: pre-commit, pre-publish, all, staged, or worktree. pre-commit
--allowlist <path> Optional allowlist file. Relative paths resolve from the repository root. .amplihack-artifact-allowlist when present
Mode Sources checked Typical use
pre-commit Staged, tracked, and untracked (committable candidates) Local pre-commit hook
pre-publish Staged, tracked, and untracked (committable candidates) Workflow broad-staging and PR/finalize gates
all Staged, tracked, untracked, and ignored-present artifact candidates Manual full safety scan
staged Staged paths only Diagnose why a commit is blocked
worktree Tracked, untracked, and ignored-present artifact candidates Check local leftovers before cleanup or staging

pre-commit and pre-publish are commit/publish gates. They only scan paths that could actually reach a commit or publication (staged, tracked, and untracked-but-not-ignored), so gitignored cache leftovers such as .pytest_cache/ or node_modules/ never block them. Use all or worktree when you also want to surface ignored-present local leftovers for cleanup. Use staged only for focused debugging because it does not detect ignored or untracked leftovers.

Exit codes:

Code Meaning
0 No prohibited artifacts were found
1 Prohibited artifacts were found
2 The guard could not complete because configuration, paths, mode, allowlist, or Git state was invalid

Violation output (this example is from a --mode all scan, which is the only class of mode that reports an ignored-present source alongside staged and untracked):

Artifact Guard blocked 3 prohibited artifact paths.

source           path                  rule
staged           dist/plugin.js        plugin-bundle
ignored-present  node_modules/         dependency-tree
untracked        .cache/               cache-artifact

Remediation:
  - Move generated, plugin, cache, and runtime output into an isolated directory.
  - Remove local artifact leftovers from the parent worktree.
  - If the artifact is intentional source material, add a narrow reviewed entry
    to .amplihack-artifact-allowlist.

Configuration errors exit with code 2:

Artifact Guard configuration error:
  .amplihack-artifact-allowlist:4 rejects absolute paths: /tmp/plugin.js

Fix the allowlist entry or run without the allowlist.

Default prohibited rules

Artifact Guard rejects these paths by default when they appear in the parent repository worktree:

Rule Examples Ignored-present behavior
Dependency trees node_modules/, packages/*/node_modules/ Blocked in worktree/all only
Plugin bundles dist/plugin.js, */dist/plugin.js Blocked in worktree/all only
Claude runtime .claude/runtime/ Exempt as untracked/ignored-present; blocked only when staged or tracked (except the launcher-owned files — see below)
Nested worktrees worktrees/ Blocked in worktree/all only
Cache directories .cache/, .npm/, .pnpm-store/, .yarn/cache/, .turbo/, .parcel-cache/, .pytest_cache/ Blocked in worktree/all only
Build output dist/, build/, coverage/, .next/, out/, logs/, outputs/, index.scip Blocked in worktree/all only
Rust build output target/ Blocked only when staged, tracked, or untracked
Generated indexes and logs index.scip, generated runtime logs, generated output directories Blocked in worktree/all only

The Ignored-present behavior column describes what happens when a prohibited path exists in the worktree but is untracked and gitignored. These paths are only blocked in the local-hygiene modes (worktree, all). In the commit and publication gates (pre-commit, pre-publish), the same path is not blocked as ignored-present, because it cannot be committed or published. Every rule still blocks fail-closed in all modes when the path is staged, tracked, or untracked-but-not-ignored.

Rules match normalized repo-relative paths using / separators. The guard does not need to read artifact file contents; path-level scanning is the intended contract.

Built-in .claude/runtime/ exemption

The amplihack launcher, session tracker, and every agent's PostToolUse metrics hook write bookkeeping into <repo>/.claude/runtime/ continuously as a normal, unavoidable part of launching and running an agent: launcher context, session logs, metrics (metrics/post_tool_use_metrics.jsonl, appended on every tool call), locks, and power-steering state all land here while a recipe runs. This subtree is .gitignored, tool-generated runtime state.

The guard exempts the whole .claude/runtime/ subtree when it appears as untracked or ignored-present output — the form this unavoidable runtime state always takes. This holds in every mode, including the fail-closed pre-commit and pre-publish gates:

Path Untracked / ignored-present Staged or tracked
.claude/runtime/ (whole subtree) Exempt Blocked as claude-runtime
.claude/runtime/launcher_context.json Exempt Exempt (launcher-owned)
.claude/runtime/sessions.jsonl Exempt Exempt (launcher-owned)

Deliberately committing runtime state into the published tree (a staged or tracked .claude/runtime/ path) is genuine pollution and is still blocked — except the two launcher-owned bookkeeping files, which are exempt in all git sources because the launcher itself may stage them.

These are built-in implicit exemptions, not .amplihack-artifact-allowlist entries (and .claude/runtime cannot be broadly allowlisted anyway — it is a root-prohibited exemption). Because the untracked/ignored exemption lives in rule_for_path, it also removes that runtime output from the worktree/all audit modes; that is intentional, since always-present regenerated runtime state is not actionable pollution.

Before this exemption, files under .claude/runtime/ (originally the launcher's own launcher_context.json, and later the metrics file that agents append to on every tool call) failed the end-of-run pre-publish guard, which left recipe-runner-rs and its child agents hung after the work was already committed and pushed (issue #807).

Allowlist configuration

The default allowlist path is .amplihack-artifact-allowlist. This file is repo-controlled configuration and should be reviewed like a security-sensitive change because it can permit generated artifacts that the guard would otherwise block.

Format:

# Blank lines and comments are ignored.
# Entries are repo-relative and use / separators.

tests/fixtures/plugin-output/dist/plugin.js
docs/fixtures/generated-manifests/*.json
examples/minimal-node-project/node_modules/.package-lock.json

Matching semantics:

Rule Behavior
Path root Entries are relative to the repository root
Separators / only; Windows-style \ separators are rejected
Case Case-sensitive matching
Exact paths tests/fixtures/dist/plugin.js matches only that path
*, ? Supported within a path segment
** Supported across path segments
Directory entries Must use an explicit suffix such as tests/fixtures/output/**; a bare directory does not imply recursive matching
Duplicates Duplicate entries are allowed but normalized to one effective rule
Comments Lines beginning with # after optional whitespace are ignored

Valid entries are narrow and intentional:

tests/fixtures/plugin-output/dist/plugin.js
tests/fixtures/plugin-output/dist/**
examples/generated-output/build/expected-manifest.json

Invalid entries fail closed with exit code 2:

/absolute/path
../outside-repo
node_modules/
node_modules/**
**/node_modules/**
dist/*
dist/
dist/**
*.log
build/**
*
**/*

Directory allowlists are accepted only for narrow fixture or example paths. They must not exempt a default prohibited directory directly at the repository root or across the whole repository.

Workflow and pre-commit coverage

Artifact Guard will run before every broad staging operation in the bundled recipes, not just publish and finalize. The initial guarded recipe set is every recipe that currently invokes git add -A:

Recipe Guard placement
workflow-finalize.yaml Before final broad staging
workflow-publish.yaml Before publication staging and before any broad staging in remediation paths
workflow-refactor-review.yaml Before broad staging
workflow-tdd.yaml Before broad staging
workflow-pr-review.yaml Before broad staging
consensus-publish.yaml Before broad staging
consensus-pr-feedback.yaml Before broad staging

Future recipe changes must preserve the rule: any new git add -A or equivalent broad-staging step needs an Artifact Guard gate immediately before it.

Before those gates run, bundled workflows also run the narrow workflow runtime preflight documented in Workflow Runtime Artifacts Reference. That preflight removes only known workflow-owned .claude/runtime and root-level worktrees/ leftovers from the active task worktree. In amplihack-managed task worktrees, root-level worktrees/ is reserved for workflow-owned nested scratch worktrees; tracked source under that path is a repository layout conflict and must fail closed rather than be deleted. Artifact Guard itself remains non-mutating and still fails on every unexpected artifact.

The checked-in pre-commit hook scans the repository's committable state — staged, tracked, and untracked-but-not-ignored paths. It does not block on ignored-present leftovers (that is what --mode worktree/all are for). It is defined in .pre-commit-config.yaml, and that file is the source of truth for the hook contract:

- repo: local
  hooks:
    - id: artifact-guard
      name: amplihack artifact guard
      entry: bash -c 'CARGO_TARGET_DIR="${TMPDIR:-/tmp}/amplihack-precommit-target" cargo run --bin amplihack -- hygiene artifact-guard --repo . --mode pre-commit'
      language: system
      pass_filenames: false
      always_run: true

pass_filenames: false is required. Git normally passes only staged filenames to pre-commit hooks, which would miss untracked artifact leftovers that a later git add -A could sweep into the commit. Artifact Guard must inspect repository state itself.

Run the hook through pre-commit:

pre-commit run artifact-guard --all-files

Or run the same guard command directly from a source checkout:

CARGO_TARGET_DIR="${TMPDIR:-/tmp}/amplihack-precommit-target" \
  cargo run --bin amplihack -- hygiene artifact-guard --repo . --mode pre-commit

Contract tests parse the hook entry as shell tokens so legal Cargo option ordering does not matter. These source-checkout forms are equivalent for the Artifact Guard contract:

CARGO_TARGET_DIR="${TMPDIR:-/tmp}/amplihack-precommit-target" \
  cargo run --bin amplihack -- hygiene artifact-guard --repo . --mode pre-commit

CARGO_TARGET_DIR="${TMPDIR:-/tmp}/amplihack-precommit-target" \
  cargo run --locked --bin amplihack -- hygiene artifact-guard --repo . --mode pre-commit

CARGO_TARGET_DIR="${TMPDIR:-/tmp}/amplihack-precommit-target" \
  cargo run --bin amplihack --locked -- hygiene artifact-guard --repo . --mode pre-commit

--locked is a Cargo option, not an Artifact Guard argument, even when it appears between cargo run and --bin. CARGO_TARGET_DIR must still isolate build output outside the repository; the checked-in hook uses the shell-expanded temp path ${TMPDIR:-/tmp}/amplihack-precommit-target.

Output isolation

The preferred fix for a violation is output isolation, not allowlisting. Allowlisting is only for intentional checked-in fixtures or reviewed generated artifacts.

These locations are intentionally not prohibited by default:

<repo>/.amplihack/runtime/
<repo>/.amplihack/cache/
<repo>/.amplihack/generated/
<git-common-dir>/.claude/runtime/
/tmp/amplihack-<purpose>-<id>/

Use CARGO_TARGET_DIR for workflow-owned Rust builds that need to avoid the repo-local target/ directory:

CARGO_TARGET_DIR=.amplihack/cache/cargo-target cargo test --workspace

Avoid writing generated output directly to:

<repo>/node_modules/
<repo>/dist/plugin.js
<repo>/worktrees/
<repo>/build/

Workflow runtime cleanup preflight

default-workflow and recovery flows use external runtime roots for generated agent state, provenance, logs, metrics, and reflection output. The runtime root contract is documented in Workflow Runtime Isolation.

As defense-in-depth, workflow lifecycle steps run preflight_known_workflow_runtime_artifacts "$worktree" before checkpoint, broad staging, publish, pre-commit-related staging, and finalization/status gates. The preflight is intentionally narrower than Artifact Guard:

Path Preflight behavior Artifact Guard behavior if still present
.claude/runtime Remove when it is exactly under the active task worktree. Exempt when untracked/ignored; blocked as claude-runtime only if staged or tracked.
worktrees/ Remove when it is exactly under the active task worktree and not tracked source. Block as nested-worktrees.
.claude/settings.json Preserve. Not blocked by the runtime rule.
Unrelated untracked files Preserve. Block when they match prohibited artifact rules or dirty-worktree gates.

The preflight is not an allowlist. It is a cleanup step for workflow-owned runtime paths that should have been isolated outside the worktree. If cleanup fails or the paths remain afterward, the lifecycle gate fails visibly.

Intended Rust API

The guard core should live in amplihack_utils::artifact_guard so CLI commands, recipes, and tests share one implementation. This is the intended public shape; implementation may rename fields only if this document is updated in the same change.

pub struct ArtifactGuardConfig {
    pub repo_path: PathBuf,
    pub mode: ArtifactGuardMode,
    pub allowlist_path: Option<PathBuf>,
}

pub enum ArtifactGuardMode {
    All,
    Staged,
    Worktree,
    PreCommit,
    PrePublish,
}

impl ArtifactGuardMode {
    /// Whether this mode scans staged (indexed) paths.
    fn scans_staged(self) -> bool;

    /// Whether this mode scans tracked and untracked worktree paths.
    fn scans_worktree(self) -> bool;

    /// Whether this mode scans ignored-present artifacts (gitignored,
    /// untracked paths that exist in the worktree).
    ///
    /// Returns `true` only for the local-hygiene modes `All` and `Worktree`.
    /// The commit/publish gates `PreCommit`, `PrePublish`, and `Staged` return
    /// `false`, because a gitignored path can never be committed or published
    /// and is outside the guard's threat model for those gates.
    ///
    /// Concretely: `matches!(self, Self::All | Self::Worktree)`.
    fn scans_ignored_present(self) -> bool;
}

pub enum ArtifactSource {
    Staged,
    Tracked,
    Untracked,
    IgnoredPresent,
}

pub struct ArtifactViolation {
    pub path: String,
    pub source: ArtifactSource,
    pub rule_id: String,
    pub message: String,
}

pub struct ArtifactGuardReport {
    pub repo_root: PathBuf,
    pub mode: ArtifactGuardMode,
    pub violations: Vec<ArtifactViolation>,
}

pub fn run_artifact_guard(
    config: ArtifactGuardConfig,
) -> Result<ArtifactGuardReport, ArtifactGuardError>;

run_artifact_guard resolves the repository root, validates the allowlist, collects candidate paths from Git, applies prohibited rules, applies allowlist exceptions, and returns a structured report. It must not mutate the repository.

Errors are fail-closed:

Error class Examples
Repository errors --repo is not a Git worktree, Git command fails
Path errors Path escapes repo root, path cannot be normalized
Mode errors Unknown CLI mode
Allowlist errors Unreadable file, absolute path, parent traversal, broad exemption

Fixing violations

For a blocked commit:

amplihack hygiene artifact-guard --repo . --mode staged
git restore --staged dist/plugin.js

Then move the build output to an isolated location or remove the local artifact if it is not needed.

For ignored leftovers before publication:

amplihack hygiene artifact-guard --repo . --mode all

Use --mode all (or --mode worktree) to surface ignored-present leftovers. The pre-commit and pre-publish gates deliberately do not report them, because gitignored, untracked paths cannot be committed or published. If the guard reports node_modules/, .pytest_cache/, .cache/, or another ignored-present artifact under those hygiene modes, relocate or remove the local output. Do not treat .gitignore as approval to keep parent-worktree pollution — clean it up locally even though it will not block a commit or a publish.

For an intentional fixture, add the narrowest allowlist entry that preserves the test:

tests/fixtures/plugin-output/dist/plugin.js

Commit the allowlist change with the fixture. Reviewers should confirm the artifact is necessary, deterministic, and safe to keep in the repository.

Worked example: cache leftovers do not block a commit or publish

This example reproduces the scenario from issue #928. A normal test or install run leaves gitignored, untracked cache directories in the worktree. Those directories can never be committed or published, so the commit and publication gates must pass. The exit codes below assume the #928 narrowing is applied — that is, ArtifactGuardMode::scans_ignored_present() returns true only for All and Worktree.

Set up a repository whose .gitignore excludes common cache directories, then create those directories as untracked leftovers:

cd my-repo
printf '.pytest_cache/\nnode_modules/\n' >> .gitignore
mkdir -p .pytest_cache node_modules/some-dep
touch .pytest_cache/CACHEDIR.TAG node_modules/some-dep/index.js

git status --ignored confirms both directories are ignored and untracked. The commit and publication gates pass (exit code 0) because ignored-present paths are outside their scope:

amplihack hygiene artifact-guard --repo . --mode pre-commit   # exit 0
amplihack hygiene artifact-guard --repo . --mode pre-publish  # exit 0

The local-hygiene modes still surface the same leftovers (exit code 1) so you can clean them up before they accumulate:

amplihack hygiene artifact-guard --repo . --mode worktree     # exit 1
amplihack hygiene artifact-guard --repo . --mode all          # exit 1

The gates remain fail-closed for anything that could reach a commit. A cache directory that is not covered by .gitignore is untracked-but-committable, so it still blocks pre-commit and pre-publish:

git rm -r --cached --ignore-unmatch . >/dev/null 2>&1 || true
: > .gitignore   # stop ignoring the cache dirs
amplihack hygiene artifact-guard --repo . --mode pre-publish  # exit 1

Likewise, staging or tracking a prohibited artifact blocks every gate mode:

git add -f dist/plugin.js
amplihack hygiene artifact-guard --repo . --mode pre-commit   # exit 1
amplihack hygiene artifact-guard --repo . --mode pre-publish  # exit 1
Scenario pre-commit / pre-publish worktree / all
Gitignored, untracked .pytest_cache/, node_modules/ Pass (exit 0) Flag (exit 1)
Untracked cache dir not in .gitignore Flag (exit 1) Flag (exit 1)
Staged or tracked prohibited artifact Flag (exit 1) Flag (exit 1)

Review expectations

Review these changes carefully:

  1. New or changed .amplihack-artifact-allowlist entries.
  2. Changes to prohibited rules.
  3. Recipe edits around git add -A, publication, finalization, or PR creation.
  4. Pre-commit changes that remove pass_filenames: false.
  5. Build, plugin, or runtime changes that redirect outputs into the parent worktree.

Reviewers should verify that generated and runtime outputs are isolated, guard failures are visible, and allowlist entries are not used as substitutes for proper output placement.