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
- Command-line interface
- Default prohibited rules
- Allowlist configuration
- Workflow and pre-commit coverage
- Output isolation
- Workflow runtime cleanup preflight
- Intended Rust API
- Fixing violations
- Worked example: cache leftovers do not block a commit or publish
Behavior¶
Artifact Guard:
- Scan repo-relative paths only.
- Check staged paths before commit.
- Check tracked and untracked paths before broad staging and workflow publication.
- Check ignored-present artifact paths only in the
worktreeandallmodes, which are local-hygiene scans. The commit- and publication-gate modes (pre-commit,pre-publish) never block on ignored-present artifacts. - Fail closed on invalid configuration, invalid paths, Git failures, and unsafe allowlist entries.
- Print actionable remediation before exiting.
- 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¶
| 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:
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:
Avoid writing generated output directly to:
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:
Then move the build output to an isolated location or remove the local artifact if it is not needed.
For ignored leftovers before publication:
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:
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:
- New or changed
.amplihack-artifact-allowlistentries. - Changes to prohibited rules.
- Recipe edits around
git add -A, publication, finalization, or PR creation. - Pre-commit changes that remove
pass_filenames: false. - 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.