Skip to content

Make the forbidden-path block depth-agnostic for bare relative globs (#177) - #178

Merged
JeremySNR merged 1 commit into
mainfrom
claude/serene-sagan-pidby2
Jul 1, 2026
Merged

JeremySNR merged 1 commit into
mainfrom
claude/serene-sagan-pidby2

Conversation

@JeremySNR

@JeremySNR JeremySNR commented Jul 1, 2026 •

Copy link
Copy Markdown
Owner

Closes #177.

Problem

The forbidden-path block — the sticky, non-retryable BLOCKED gate, the strongest action the control plane takes on a diff — silently under-matched an operator-configured bare relative glob against nested paths. _forbidden_violations matched via the shared engines.risk.glob_match, which only special-cases a leading **/. fnmatch anchors at the string start, so:

pattern app/secrets/key.pem services/api/migrations/0001.py
secrets/** (bare) False ⟵ bug —
migrations/** (bare) — False ⟵ bug
**/secrets/** True —

The built-in DEFAULT_FORBIDDEN_GLOBS dodged this only because they were hand-authored to ship both variants (added for #22). But an operator who overrides policy.forbidden_globs (or adds policy.repo_forbidden_globs) with the natural ["secrets/**"] got a gate that hard-blocks a top-level secrets/ change but lets a nested app/secrets/key.pem through with only the softer sensitive-area REVIEW_REQUIRED — not the sticky BLOCK they configured. foundry-policy explain shows the glob as present, reinforcing a false sense of coverage.

Change

  • New engines.risk.forbidden_path_match — a bare relative pattern is treated as depth-agnostic (secrets/** blocks app/secrets/key.pem); a rooted (/…) or already-anchored (**/…) pattern is honoured exactly as written. It returns a strict superset of glob_match.
  • Wired into _forbidden_violations only (the single forbidden-path enforcement site).
  • glob_match left unchanged. It also backs files_outside_scope / _scope_entry_covers (plan-scope drift), where matching more paths marks more files "in scope" → fewer escalations → a weaker gate. So the fix is deliberately forbidden-path-specific, not a change to the shared matcher.
  • Comments in config.py / planner.py updated to note the default **/… twins are now belt-and-suspenders; AGENTS.md module-map row updated per the maintenance rule.

Why it's safe (invariants)

Tests

  • test_forbidden_path_match_is_depth_agnostic_for_bare_globs — bare relative matches at depth; rooted/anchored honoured; superset of glob_match; genuine non-matches (secretsmanager/) stay non-matches.
  • test_custom_bare_forbidden_glob_blocks_a_nested_path — end-to-end: a custom secrets/** hard-blocks a nested app/secrets/… diff, and the block stays sticky.
  • Full offline suite green locally (1612 passed) and ruff clean.

Risks / follow-up

  • Low risk: additive/stricter-only, single call site, no gate-contract change.
  • Behavioural note for reviewers: a bare filename forbidden glob (e.g. Dockerfile) now also matches at any depth (build/Dockerfile). That is the intended stricter-is-safe direction for a forbidden list; a rooted /Dockerfile still pins it to the root.
  • Environment caveat (not from this diff): the OIDC/encryption/PDF test modules fail to import in this sandbox due to a cryptography Rust-binding panic (system/pip version conflict); those tests are unrelated to this change and untouched by it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SvTTiFQsksv1Pv69CHPw25


Generated by Claude Code


Note

Low Risk
Stricter-only orchestrator change at a single call site; no Rego or policy contract change. Bare filename globs (e.g. Dockerfile) now match at any depth, which is intentional for forbidden lists.

Overview
Fixes #177: operator bare relative forbidden globs (e.g. secrets/**, migrations/**) no longer only match repo-root paths. The sticky forbidden-path block now uses a dedicated forbidden_path_match matcher so nested paths like app/secrets/key.pem hard-BLOCK instead of slipping through with only sensitive-area escalation.

glob_match is unchanged — it still backs plan-scope drift and path approval roles, where broader matching would weaken gates.

Docs/comments in AGENTS.md, config.py, and planner.py note that default **/… twins are belt-and-suspenders under the new matcher. Unit tests cover the matcher; an orchestrator test asserts custom secrets/** blocks nested paths and stays sticky.

Reviewed by Cursor Bugbot for commit 9bc7f7d. Bugbot is set up for automated code reviews on this repo. Configure here.

…177)

The sticky forbidden-path BLOCK matched operator-configured globs via the
shared `glob_match`, which only special-cases a leading `**/`. So a bare
relative pattern like `secrets/**` or `migrations/**` matched only a
top-level dir: a nested `app/secrets/key.pem` slipped past the sticky BLOCK
with merely the softer sensitive-area REVIEW_REQUIRED escalation. The
built-in defaults dodged this only by shipping explicit `**/...` twins.

Add `engines.risk.forbidden_path_match`: a bare relative pattern is treated
as depth-agnostic (matched at any directory depth), while a rooted (`/...`)
or already-anchored (`**/...`) pattern is honoured as written. It matches a
strict superset of `glob_match`, so it can only ever make the block stricter
(invariant #1) and leaves default-config behaviour byte-for-byte unchanged.

Deliberately not folded into `glob_match`, which also backs
`files_outside_scope` (plan-scope drift) where matching more paths would
*weaken* that escalation. Wire it into `_forbidden_violations` only.

Forbidden-path blocking is orchestrator-only with no Rego mirror, so no
gate/PolicyInput/Rego/schema change (invariant #2 doesn't apply). Adds a
unit test for the matcher and an end-to-end test that a custom bare
`secrets/**` hard-blocks a nested diff. AGENTS.md updated.

Closes #177

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvTTiFQsksv1Pv69CHPw25

@JeremySNR JeremySNR left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review — what a human reviewer should double-check:

1. Semantic breadth of forbidden_path_match for bare globs. The core decision is that a bare relative pattern is matched at any depth. This is intentional and stricter-is-safe for a forbidden list, but it does broaden matching beyond what the operator literally typed:

  • secrets/** now also blocks app/secrets/…, services/x/secrets/…, etc. (the fix's whole point). ✅
  • A bare filename glob like Dockerfile or *.pem now also matches nested (build/Dockerfile). For a forbidden list this is the desired direction, but confirm no one was relying on a bare filename meaning "root only." The escape hatch is documented and tested: a rooted /Dockerfile pins it to the repo root.
  • I verified secrets/** does not match sibling-prefixed dirs like src/secretsmanager/util.py (segment-boundary, not substring) — test covers it.

2. Scope containment — why glob_match was left alone. glob_match also backs files_outside_scope (plan-scope-drift escalation), where more matching means fewer files flagged outside scope → a weaker escalate-only gate. Making the shared matcher permissive would silently relax that gate (invariant #1 violation). I confirmed forbidden_path_match is used only at the single forbidden enforcement site (_forbidden_violations); the path_required_roles matcher at the same layer still uses glob_match (correct — that path is a different, unrelated rule). Please sanity-check there's no other consumer that should arguably get the depth-agnostic treatment.

3. No gate-contract change. Forbidden-path blocking is orchestrator-only with no Rego mirror, so there is no foundry.rego / PolicyInput / policy_vectors edit and invariant #2's lock-step doesn't apply. Nothing in the policy engine or schemas changed.

4. Default globs untouched. I kept the redundant **/… twins in DEFAULT_FORBIDDEN_GLOBS rather than pruning them — removing entries would churn the policy-comparison / preset-strictness tests for no behavioural gain, and they remain valid belt-and-suspenders.

CI note: locally the full offline suite is green (1612 passed); the OIDC/encryption/PDF modules can't import in my sandbox due to a cryptography Rust-binding panic (a system/pip version conflict in the sandbox, not this diff — those files are untouched). CI runs them in a clean environment, so watch those jobs here.


Generated by Claude Code

@JeremySNR
JeremySNR merged commit 8425142 into main Jul 1, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Forbidden-path gate under-matches operator-configured bare relative globs at depth (secrets/** misses app/secrets/)

2 participants