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
4 changes: 3 additions & 1 deletion internal/infra/review/auto.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ func (a *AutoAcceptReviewer) Review(changes []snapshot.FileChange) (snapshot.Rev
case snapshot.TierAutoExec:
result.Rejected = append(result.Rejected, ch)
rejected++
_, _ = fmt.Fprintf(a.stderr, "REJECTED: %s — %s\n", ch.RelPath, reason)
_, _ = fmt.Fprintf(a.stderr,
"REJECTED: %s — %s (re-run with --review to approve)\n",
ch.RelPath, reason)
a.logger.Warn("auto-rejected security-sensitive path",
"path", ch.RelPath, "tier", tier.String(), "reason", reason)
case snapshot.TierBuildCI:
Expand Down
12 changes: 7 additions & 5 deletions internal/infra/review/auto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,15 @@ func TestAutoAcceptReviewer_MixedTiers(t *testing.T) {
result, err := r.Review(changes)
require.NoError(t, err)

// Tier 1 (.git/hooks, .envrc) rejected; Tier 2 (.github/workflows) + normal accepted.
assert.Len(t, result.Rejected, 2)
assert.Len(t, result.Accepted, 2)
// Tier 1 (.git/hooks) rejected; Tier 2 (.github/workflows, .envrc) +
// normal accepted. .envrc is now Tier 2 because direnv gates
// execution via `direnv allow`, a user-action-required step.
assert.Len(t, result.Rejected, 1)
assert.Len(t, result.Accepted, 3)

output := stderr.String()
assert.Contains(t, output, "2 file(s) auto-rejected")
assert.Contains(t, output, "1 file(s) warned")
assert.Contains(t, output, "1 file(s) auto-rejected")
assert.Contains(t, output, "2 file(s) warned")
}

func TestAutoAcceptReviewer_NoSummaryForNormalFiles(t *testing.T) {
Expand Down
49 changes: 48 additions & 1 deletion pkg/domain/snapshot/sensitive.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,28 @@ func exactRootRule(name, reason string, tier SensitivityTier) SensitivePathRule
}
}

// pathSuffixRule matches a path whose trailing components exactly
// equal suffix — e.g. suffix = ".vscode/tasks.json" matches both the
// workspace-root `.vscode/tasks.json` AND a monorepo-nested
// `packages/foo/.vscode/tasks.json`. The separator-aware check
// prevents false positives on paths like `foo.vscode/tasks.json`
// (filename that happens to end with ".vscode").
func pathSuffixRule(suffix, reason string, tier SensitivityTier) SensitivePathRule {
return SensitivePathRule{
Match: func(relPath string) bool {
return relPath == suffix || strings.HasSuffix(relPath, "/"+suffix)
},
Tier: tier,
Reason: reason,
}
}

// DefaultSensitivePathRules returns the built-in set of sensitive path rules.
func DefaultSensitivePathRules() []SensitivePathRule {
return []SensitivePathRule{
// Tier 1 — auto-exec on host (no explicit user action needed)
prefixRule(".git/hooks/", "git hook — auto-executes on git operations", TierAutoExec),
prefixRule(".husky/", "husky git hook — auto-executes on git operations", TierAutoExec),
exactRootRule(".envrc", "direnv config — auto-executes on directory entry", TierAutoExec),
exactRootRule(".pre-commit-config.yaml", "pre-commit config — auto-executes on git commit", TierAutoExec),

// Tier 2 — CI/build (requires explicit user action)
Expand Down Expand Up @@ -129,6 +144,38 @@ func DefaultSensitivePathRules() []SensitivePathRule {
// Flushing in-progress rebase state is a legitimate workflow
// (resume a rebase started in the VM), so warn rather than block.
exactRootRule(".git/rebase-merge/git-rebase-todo", "rebase todo — `exec` directives run on `git rebase --continue`", TierBuildCI),
// VSCode executes tasks.json tasks on user action (F5, Run Task
// palette) and launch.json configurations on debug start. Flag
// only these two — .vscode/settings.json / extensions.json carry
// no exec surface and routine edits would be noisy.
pathSuffixRule(".vscode/tasks.json", "VSCode tasks — execute on user action (Run Task, F5)", TierBuildCI),
pathSuffixRule(".vscode/launch.json", "VSCode launch config — runs debugger command on debug start", TierBuildCI),
// Devcontainer's postCreateCommand / postStartCommand execute on
// "Reopen in Container". Flag only the config entrypoints; users
// edit Dockerfiles and scripts in .devcontainer/ routinely.
exactRootRule(".devcontainer.json", "devcontainer config — lifecycle commands execute on Reopen in Container", TierBuildCI),
exactRootRule(".devcontainer/devcontainer.json", "devcontainer config — lifecycle commands execute on Reopen in Container", TierBuildCI),
// package.json scripts (preinstall/install/postinstall/prepare/
// prepublish) execute on npm install / npm publish. Warn-level
// so routine dependency-bump edits by the agent still flow
// through — a compromised agent is expected to visibly modify
// package.json scripts for the attack to fire.
basenameRule("package.json", "Node package manifest — scripts.{pre,post}{install,prepare,…} execute on npm/yarn install", TierBuildCI),
// pyproject.toml build-system hooks and script entries run on
// `pip install` / `poetry install`. Same reasoning as package.json.
basenameRule("pyproject.toml", "Python project manifest — build hooks and scripts execute on pip/poetry install", TierBuildCI),
// setup.py is arbitrary Python that runs on `pip install .` or
// `python setup.py <cmd>`. Explicit user action required, so
// warn-level.
basenameRule("setup.py", "Python setup script — executes on pip install . or python setup.py", TierBuildCI),
// direnv walks upward from the user's shell cwd, so a `.envrc`
// in any subdirectory fires on `cd sub/`. direnv gates execution
// behind `direnv allow` after each change (similar to how
// package.json scripts require `npm install`), which makes this
// a user-action-required exec surface — Tier 2 warn rather than
// Tier 1 reject, so routine edits in direnv-heavy monorepos
// aren't destroyed by auto-accept.
basenameRule(".envrc", "direnv config — runs on `cd` after `direnv allow`", TierBuildCI),
}
}

Expand Down
32 changes: 30 additions & 2 deletions pkg/domain/snapshot/sensitive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ func TestClassifyPath(t *testing.T) {
{name: "git hook pre-commit", relPath: ".git/hooks/pre-commit", wantTier: TierAutoExec, wantSense: true},
{name: "git hook post-merge", relPath: ".git/hooks/post-merge", wantTier: TierAutoExec, wantSense: true},
{name: "husky hook", relPath: ".husky/pre-commit", wantTier: TierAutoExec, wantSense: true},
{name: "envrc at root", relPath: ".envrc", wantTier: TierAutoExec, wantSense: true},
{name: "pre-commit config", relPath: ".pre-commit-config.yaml", wantTier: TierAutoExec, wantSense: true},

// Tier 2 — CI/build
Expand All @@ -43,6 +42,22 @@ func TestClassifyPath(t *testing.T) {
{name: "gitattributes nested", relPath: "sub/dir/.gitattributes", wantTier: TierBuildCI, wantSense: true},
{name: "git info attributes", relPath: ".git/info/attributes", wantTier: TierBuildCI, wantSense: true},
{name: "git rebase todo", relPath: ".git/rebase-merge/git-rebase-todo", wantTier: TierBuildCI, wantSense: true},
{name: "vscode tasks root", relPath: ".vscode/tasks.json", wantTier: TierBuildCI, wantSense: true},
{name: "vscode tasks nested", relPath: "packages/foo/.vscode/tasks.json", wantTier: TierBuildCI, wantSense: true},
{name: "vscode launch root", relPath: ".vscode/launch.json", wantTier: TierBuildCI, wantSense: true},
{name: "devcontainer.json at root", relPath: ".devcontainer.json", wantTier: TierBuildCI, wantSense: true},
{name: "devcontainer dir config", relPath: ".devcontainer/devcontainer.json", wantTier: TierBuildCI, wantSense: true},
{name: "package.json at root", relPath: "package.json", wantTier: TierBuildCI, wantSense: true},
{name: "package.json nested monorepo", relPath: "packages/foo/package.json", wantTier: TierBuildCI, wantSense: true},
{name: "pyproject.toml at root", relPath: "pyproject.toml", wantTier: TierBuildCI, wantSense: true},
{name: "setup.py at root", relPath: "setup.py", wantTier: TierBuildCI, wantSense: true},
// direnv walks upward: a subdir .envrc fires on `cd sub/`, so
// it must still be flagged. Classified TierBuildCI (warn, not
// auto-reject) because direnv gates execution behind
// `direnv allow` — a user-action-required exec surface similar
// to npm install triggering package.json scripts.
{name: "envrc at root (tier2)", relPath: ".envrc", wantTier: TierBuildCI, wantSense: true},
{name: "envrc in subdir", relPath: "sub/.envrc", wantTier: TierBuildCI, wantSense: true},

// Not sensitive
{name: "normal go file", relPath: "main.go", wantTier: TierNone, wantSense: false},
Expand All @@ -52,11 +67,24 @@ func TestClassifyPath(t *testing.T) {
{name: "git packed-refs benign", relPath: ".git/packed-refs", wantTier: TierNone, wantSense: false},
{name: "git rebase patch file", relPath: ".git/rebase-apply/0001.patch", wantTier: TierNone, wantSense: false},
{name: "git sequencer todo", relPath: ".git/sequencer/todo", wantTier: TierNone, wantSense: false},
{name: "envrc in subdir", relPath: "sub/.envrc", wantTier: TierNone, wantSense: false},
{name: "travis in subdir", relPath: "sub/.travis.yml", wantTier: TierNone, wantSense: false},
{name: "pre-commit in subdir", relPath: "sub/.pre-commit-config.yaml", wantTier: TierNone, wantSense: false},
{name: "github non-workflow", relPath: ".github/CODEOWNERS", wantTier: TierNone, wantSense: false},
{name: "gitlab-ci in subdir", relPath: "sub/.gitlab-ci.yml", wantTier: TierNone, wantSense: false},
// Non-exec IDE files: pure configuration state, not flagged to avoid
// noisy warnings on every routine edit.
{name: "vscode settings benign", relPath: ".vscode/settings.json", wantTier: TierNone, wantSense: false},
{name: "vscode extensions benign", relPath: ".vscode/extensions.json", wantTier: TierNone, wantSense: false},
// .vscode/tasks.json suffix check is separator-aware — filenames
// ending with ".vscode" must NOT match the suffix rule.
{name: "false-positive suffix", relPath: "foo.vscode/tasks.json", wantTier: TierNone, wantSense: false},
// Devcontainer auxiliary files (Dockerfile, setup scripts, etc.)
// are not flagged; only the config entrypoint is.
{name: "devcontainer dockerfile benign", relPath: ".devcontainer/Dockerfile", wantTier: TierNone, wantSense: false},
// package-lock.json / poetry.lock are tool-generated and not
// relevant to exec surface.
{name: "package-lock benign", relPath: "package-lock.json", wantTier: TierNone, wantSense: false},
{name: "poetry lock benign", relPath: "poetry.lock", wantTier: TierNone, wantSense: false},

// Defense-in-depth: non-canonical paths cleaned before matching
{name: "non-canonical git hook", relPath: ".git/hooks/../hooks/pre-commit", wantTier: TierAutoExec, wantSense: true},
Expand Down