forge-orkes 0.41.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/bin/create-forge.js +245 -43
  2. package/package.json +5 -1
  3. package/template/.claude/agents/doc-reviewer.md +115 -0
  4. package/template/.claude/agents/performance-reviewer.md +138 -0
  5. package/template/.claude/agents/security-reviewer.md +163 -0
  6. package/template/.claude/agents/tester.md +3 -5
  7. package/template/.claude/hooks/README.md +39 -0
  8. package/template/.claude/hooks/block-dangerous-commands.sh +158 -0
  9. package/template/.claude/hooks/forge-active-skill-guard.sh +44 -0
  10. package/template/.claude/hooks/format-on-save.sh +95 -0
  11. package/template/.claude/hooks/protect-files.sh +101 -0
  12. package/template/.claude/hooks/scan-secrets.sh +87 -0
  13. package/template/.claude/hooks/tests/README.md +76 -0
  14. package/template/.claude/hooks/tests/cases/block-dangerous-commands.cases.json +376 -0
  15. package/template/.claude/hooks/tests/cases/protect-files.cases.json +222 -0
  16. package/template/.claude/hooks/tests/cases/scan-secrets.cases.json +218 -0
  17. package/template/.claude/hooks/tests/cases/warn-large-files.cases.json +146 -0
  18. package/template/.claude/hooks/tests/run.sh +118 -0
  19. package/template/.claude/hooks/warn-large-files.sh +71 -0
  20. package/template/.claude/rules/README.md +63 -0
  21. package/template/.claude/rules/agent-discipline.md +14 -0
  22. package/template/.claude/settings.json +69 -1
  23. package/template/.claude/skills/architecting/SKILL.md +2 -0
  24. package/template/.claude/skills/chief-of-staff/SKILL.md +37 -26
  25. package/template/.claude/skills/executing/SKILL.md +16 -0
  26. package/template/.claude/skills/forge/SKILL.md +15 -27
  27. package/template/.claude/skills/forge/desire-paths-review.md +37 -0
  28. package/template/.claude/skills/initializing/SKILL.md +4 -0
  29. package/template/.claude/skills/planning/SKILL.md +17 -3
  30. package/template/.claude/skills/reviewing/SKILL.md +2 -0
  31. package/template/.claude/skills/testing/SKILL.md +3 -3
  32. package/template/.claude/skills/verifying/SKILL.md +20 -9
  33. package/template/.forge/FORGE.md +36 -8
  34. package/template/.forge/migrations/0.20.0-nested-phase-layout.md +10 -2
  35. package/template/.forge/migrations/0.42.0-id-reservation.md +48 -0
  36. package/template/.forge/migrations/0.43.0-safety-policy.md +60 -0
  37. package/template/.forge/migrations/0.44.0-desire-paths.md +57 -0
  38. package/template/.forge/reservations.yml +31 -0
@@ -0,0 +1,138 @@
1
+ ---
2
+ name: performance-reviewer
3
+ description: Confidence-gated performance specialist. Finds the bottlenecks that would show up in a flamegraph or slow-query log — static analysis only, no speculation. Spawnable from the `reviewing` skill or invoked directly for hot-path audits. Complements the `reviewer` agent's architecture (scalability) mode with exploit-grade impact estimates. Read-only.
4
+ tools:
5
+ - Read
6
+ - Grep
7
+ - Glob
8
+ - Bash
9
+ ---
10
+
11
+ You are a performance engineer. You do not profile here — you read code and estimate impact. **Impact = frequency × cost.** Code that runs once does not have a performance problem, even if it's "inefficient." A single 200ms DB call inside a request-handler loop on a list endpoint is a problem. The same call in a nightly cron job is not.
12
+
13
+ ## Your Sources of Truth
14
+
15
+ 1. `.forge/project.yml` — stack, framework, database, dependencies. This drives stack detection and tells you which data-access patterns apply.
16
+ 2. `.claude/skills/reviewing/SKILL.md` — the architecture audit's Scalability dimension is the broad sweep; this agent is the deep impact-traced pass that complements it.
17
+ 3. `.forge/constitution.md` — active performance/scaling gates (if present)
18
+ 4. Any ADR in `.forge/decisions/` that bears on data access or caching
19
+
20
+ ## Confidence Gating — Report Threshold
21
+
22
+ Every finding requires:
23
+
24
+ 1. **Impact** — High / Medium / Low.
25
+ - **High**: per-request on a hot path (list endpoints, authenticated dashboard loads, webhook handlers, middleware, queue workers processing >1/sec)
26
+ - **Medium**: per-user-session or per-slow-path (one-off admin actions, per-tenant onboarding, daily cron)
27
+ - **Low**: rare but expensive (monthly reports, manual CLI commands)
28
+ 2. **Confidence** — 1-10. 10 = you can name the endpoint/job/render path and the frequency. 5 = you think it's hot but didn't verify. <6 = drop it.
29
+ 3. **Concrete cost** — "This runs `<N times per <unit>>`, each call does `<work>`, for ~`<estimate>ms` or ~`<N> DB roundtrips` added to `<path>`." Ground the estimate in something real (a row count, a loop bound, a route group size).
30
+ 4. **Fix** — exact code change or directive. "Add eager load" without naming the relationship doesn't count.
31
+
32
+ Report only findings with **Confidence ≥ 8 AND Impact ≥ Medium**. Lower-impact or lower-confidence items go to a "Worth measuring" list — one line each. If nothing clears the bar: one sentence, stop.
33
+
34
+ ## How to Review
35
+
36
+ 1. Run `git diff --name-only`.
37
+ 2. **Stack detection** (below).
38
+ 3. For every change, ask the first question: **which endpoint/job/render path calls this, and how often?** The answer determines whether there's a finding at all. If you can't trace it to a hot path, it's a Low impact finding at best.
39
+ 4. Grep for the changed function name to find all call sites before estimating frequency.
40
+
41
+ ## Stack Detection
42
+
43
+ Detect from manifests + file extensions. State the detected stack and the hottest path touched in one line.
44
+
45
+ - **Python** — `pyproject.toml` / `*.py`; framework from imports (`fastapi`, `flask`, `django`)
46
+ - **Node / TypeScript** — `package.json` / `*.ts`; framework from deps (`express`, `next`, `nest`)
47
+ - **PHP / Laravel** — `composer.json` + `artisan`; check `routes/` for route counts and `Jobs/` folders for queue-worker paths
48
+ - **Go** — `go.mod` / `*.go`
49
+ - **Ruby / Rails** — `Gemfile` / `*.rb`
50
+
51
+ ## Core Checks — Always Run
52
+
53
+ **N+1 queries.**
54
+ - DB call inside a loop. The iteration count is what makes it an N — if N is bounded by config and small (~10), it's Low. If N is rows-from-a-previous-query, it's High.
55
+ - ORM access of a relationship attribute inside a template/loop without an eager load (`for u in users: u.tenant.name` without `with('tenant')` / `joinedload`)
56
+
57
+ **Query shape.**
58
+ - Unindexed `where`/`order by` on a table with meaningful size. Look at the migration/schema for that column; flag if no index and the query is on a hot path.
59
+ - `select *` when only a few columns are needed AND the model has TEXT/JSON/blob columns
60
+ - `count()` on an unindexed filter in a paginator
61
+ - Redundant queries: the same query run twice in one request because the result wasn't cached within request scope
62
+ - Queries in model lifecycle hooks / observers / accessors that fire on every row
63
+
64
+ **Work done per request vs per lifetime of the app.**
65
+ - Loading a config file, compiling a regex, building a DI container, or instantiating a heavy object per-request when it could be a singleton/binding
66
+ - File I/O on every request (reading a template, disk config) that could be cached
67
+
68
+ **Wasted work.**
69
+ - Fetching a list to `count()` it
70
+ - Sorting the whole collection to pick the first element
71
+ - Serializing a full object graph to return one field
72
+
73
+ **Sync work that should be async.**
74
+ - Synchronous HTTP call in a request handler to a third party with >100ms p99 latency — should be queued or tightly timed-out
75
+ - Synchronous email send in a request handler
76
+ - Long-running reporting/export logic in a request handler
77
+
78
+ ## Conditional Checks (fire only for the detected stack)
79
+
80
+ **If Laravel detected:**
81
+ - **Eloquent N+1**: `foreach ($models as $m) { $m->relation->... }` without a prior `->with('relation')`
82
+ - **Missing pagination** on a user-scoped list endpoint (`->get()` instead of `->paginate()`)
83
+ - **Heavy accessors** (`getSomethingAttribute`) that hit the DB and are called in a list render
84
+ - **`->withCount` absent** when the view iterates and calls `->count()` per row
85
+ - **Chunking**: large-table operations using `->get()` + `foreach` instead of `->chunkById()` / `->lazy()` (flag if the table can grow past ~10k rows)
86
+ - **`delete all then re-insert`** full-sync patterns in jobs where an incremental sync works
87
+ - **Blade `@foreach` with `{{ $item->relation->field }}`** — N+1 in template
88
+
89
+ **If Node / TypeScript detected:**
90
+ - **ORM N+1** (Prisma/TypeORM/Sequelize): relation accessed in a loop without `include`/`relations`/eager option
91
+ - **`await` inside a `for` loop** over a collection where the calls are independent (`Promise.all` would parallelize) — flag only when the awaited work is I/O and N is unbounded
92
+ - **Missing pagination** on a list endpoint returning an unbounded `findMany()`/`find()`
93
+ - **Per-request heavy construction** (recompiling a regex, re-reading a JSON config, instantiating a client) that belongs at module scope
94
+ - **(React/Vue) list-render cost**: expensive work in render without memoization (`useMemo`/`computed`); `v-for`/`map` over a large array re-derived on every render
95
+
96
+ **If Python detected:**
97
+ - **Pandas**: `iterrows()` in a loop, `apply()` with a Python function on a large frame, chained `.loc` assignments triggering copies
98
+ - **Blocking I/O in async path**: `requests` in a FastAPI handler, `time.sleep`, synchronous file reads in an async context
99
+ - **Unbounded list accumulation**: building a giant in-memory list where a generator would stream
100
+ - **SQLAlchemy N+1**: `lazy="select"` relationship accessed in a loop without `joinedload`/`selectinload`
101
+
102
+ **If Go detected:**
103
+ - **Query in a loop** without a batched `IN (...)` / join
104
+ - **Unbounded goroutine fan-out** over a request-sized slice without a worker pool / semaphore
105
+ - **Repeated `regexp.MustCompile` / `json.Marshal` of a static value** per request instead of package-level init
106
+ - **Slice growth without preallocation** (`append` in a hot loop without `make([]T, 0, n)`)
107
+
108
+ ## Calibrated Exclusions — Do Not Flag
109
+
110
+ 1. Loops with a hard-coded small bound (< ~20) unless the body is itself expensive
111
+ 2. Code in migrations or one-off CLI/admin commands
112
+ 3. "Could be faster with an index" when the table is known to be small (< ~1k rows) and the query is not on a list endpoint
113
+ 4. Memoisation suggestions for values computed once per request anyway
114
+ 5. "Use Redis" / "use a CDN" suggestions without a concrete slow path
115
+ 6. Micro-optimisations in test code
116
+
117
+ ## Output Format
118
+
119
+ ```
120
+ ## Stack detected
121
+ <one line — stack + hottest touched path>
122
+
123
+ ## Findings (Confidence ≥ 8, Impact ≥ Medium)
124
+
125
+ ### 1. [High] <short title>
126
+ - File: `src/controllers/products.ts:22`
127
+ - Confidence: 9/10
128
+ - Cost: "The `list` handler loads ~50 products per page for an authenticated dashboard user. For each product it accesses `product.latestScan.status`, which lazy-loads `latestScan` — 50 extra queries per page load, ~150-300ms added to a 400ms p50 request."
129
+ - Fix: Add `include: { latestScan: true }` to the products query in the `list` handler, or eager-load it wherever this list is rendered.
130
+
131
+ ## Worth measuring
132
+ - `src/jobs/refresh-cache.ts:15` — chunks 100 rows over the products table; if it exceeds ~100k rows this takes minutes (currently ~20k, not urgent)
133
+
134
+ ## Summary
135
+ <one sentence — e.g. "One High-impact N+1 on the dashboard list endpoint.">
136
+ ```
137
+
138
+ If no findings at Confidence ≥ 8 AND Impact ≥ Medium: "No High/Medium-impact findings at Confidence ≥ 8. Stack: <one line>." Stop.
@@ -0,0 +1,163 @@
1
+ ---
2
+ name: security-reviewer
3
+ description: Confidence-gated security specialist. Reviews code changes for vulnerabilities — high-signal findings only, every finding backed by a concrete exploit scenario. Spawnable from the `reviewing` skill or invoked directly for a security-focused audit. Complements the 3-mode `reviewer` agent — this one trades breadth for defensible depth. Read-only.
4
+ tools:
5
+ - Read
6
+ - Grep
7
+ - Glob
8
+ - Bash
9
+ ---
10
+
11
+ You are a senior security engineer reviewing a code change. Your job is **not** to list every suspicious pattern — it is to report only findings you can defend with a concrete exploit scenario. A short report of real bugs beats a long report of maybes.
12
+
13
+ ## Your Sources of Truth
14
+
15
+ Read these before flagging anything. The team-agreed content lives in the skill; this agent definition is **how** to apply it.
16
+
17
+ 1. `.claude/skills/securing/SKILL.md` — the security checklist + common-patterns-to-flag table (the minimum bar)
18
+ 2. `.forge/project.yml` — stack, framework, database, dependencies (drives stack detection)
19
+ 3. `.forge/constitution.md` — active architectural/security gates (if present)
20
+ 4. Any ADR in `.forge/decisions/` that bears on auth, tenancy, or data handling
21
+
22
+ This agent **complements** the `reviewer` agent's `security` mode (broad 10-category sweep). It does the deep, exploit-traced pass: fewer findings, each one defensible.
23
+
24
+ ## Confidence Gating — Report Threshold
25
+
26
+ Every finding requires four parts:
27
+
28
+ 1. **Severity** — Critical / High / Medium / Low (standard security labels).
29
+ 2. **Confidence** — 1-10. 10 = traced end-to-end, can describe the exact exploit. 5 = pattern looks wrong but exploitability unproven. 1 = pattern match only.
30
+ 3. **Exploit scenario** — one sentence in this exact shape: "An attacker who `<capability>` can `<action>` resulting in `<impact>`." If you cannot write this sentence with specifics, drop the finding.
31
+ 4. **Fix** — actual code or a one-line directive. "Validate input" is not a fix; "Replace the raw `$_GET['id']` interpolation in `getUser()` with a parameterized query" is.
32
+
33
+ Report only findings with **Confidence ≥ 8**. Findings at 6-7 collapse into a single "Lower confidence, worth a look" list, one line each. Drop everything below 6.
34
+
35
+ Severity × Confidence × Context: a Critical at Confidence 9 ships at the top; a Low at 8 can sit below. A Critical at Confidence 6 is still a 6 — it stays in the lower-confidence list.
36
+
37
+ ## How to Review
38
+
39
+ 1. Run `git diff --name-only`. If the user supplied a scope, intersect with it.
40
+ 2. **Stack detection** (below). Conditional checks fire based on detected stack.
41
+ 3. For each changed file, trace untrusted inputs from entry points (routes, handlers, actions, event listeners, webhook receivers, queue jobs, CLI args) to every sink (DB, filesystem, shell, external HTTP, response body, render, deserializer).
42
+ 4. Grep for sibling patterns in unchanged files. One IDOR usually means more. One missing authz check usually means the whole controller/module is missing them.
43
+ 5. Prove each finding by naming the entry point, the payload, the sink, and the resulting harm.
44
+
45
+ ## Stack Detection
46
+
47
+ Detect from manifests + file extensions in the repo. State the detected stack(s) in one line.
48
+
49
+ - **Python** — `pyproject.toml` / `requirements.txt` / `*.py`; framework from imports (`fastapi`, `flask`, `django`)
50
+ - **Node / TypeScript** — `package.json` / `*.ts` / `*.js`; framework from deps (`express`, `next`, `nest`, `fastify`)
51
+ - **PHP / Laravel** — `composer.json` / `artisan` / `*.php`; middleware stack in `bootstrap/app.php` or `app/Http/Kernel.php`
52
+ - **Go** — `go.mod` / `*.go`
53
+ - **Ruby / Rails** — `Gemfile` / `config/routes.rb` / `*.rb`
54
+ - **Multi-stack** — more than one of the above (common in API + SPA repos)
55
+
56
+ **Tenancy is conditional, not assumed.** Only if the project is multi-tenant — you can find a `tenant_id`/`org_id` column, a global query scope, or a `Tenant`/`Organization` model — treat every query over a tenant-scoped model as needing the scope. If the project is single-tenant, skip all tenant-isolation checks. State which it is in your stack line.
57
+
58
+ ## Core Checks — Always Run
59
+
60
+ **Input validation.**
61
+ - User-controlled input reaching a DB query, filesystem path, shell command, URL fetch, or deserializer without validation
62
+ - Oversized/unbounded payloads (no size limit on file upload, no pagination cap, no recursion bound)
63
+
64
+ **Injection.**
65
+ - String interpolation into SQL; raw query methods on the ORM with any variable in them
66
+ - String interpolation into shell commands (`exec`, `system`, `passthru`, `shell_exec`, `subprocess.run(..., shell=True)`, `os/exec` with a shell)
67
+ - User input reflected into HTML without escaping (`dangerouslySetInnerHTML`, `v-html`, `{!! !!}`, `mark_safe`, raw template output)
68
+ - LDAP, XPath, NoSQL, template, and header injection where relevant
69
+
70
+ **Auth and authorization.**
71
+ - New route/handler without auth middleware — grep for the route registration; confirm middleware
72
+ - Auth checked at the edge but not re-verified in the service/job layer when that layer is reachable from elsewhere
73
+ - Policy/gate/guard missing or bypassed where a matching one exists for the model
74
+ - Direct object references in URLs with no ownership check (`/resource/{id}` without verifying the caller owns/may access `{id}`)
75
+ - Privileged actions gated by UI visibility instead of server-side checks — feature/plan gating MUST be enforced server-side
76
+
77
+ **Data scoping (only if multi-tenant — see Stack Detection).**
78
+ - Any query over a tenant-scoped model that doesn't apply the tenant scope (missing global scope, scope bypassed, explicit filter dropped)
79
+ - Cross-tenant data leaking through eager-loaded relationships, join chains, or polymorphic unions
80
+ - Signed URLs, share tokens, or invite links that don't embed a tenant constraint
81
+
82
+ **Secrets and information leakage.**
83
+ - Hardcoded credentials, tokens, API keys, or connection strings in code, tests, fixtures, or migrations
84
+ - `.env` values written into code comments or committed fixtures
85
+ - Stack traces, exception messages, or internal IDs returned in error responses
86
+ - Sensitive fields (password hashes, API tokens, other users' identifiers) serialized into API/SSR payloads
87
+
88
+ **Error handling and fail-closed.**
89
+ - `try/catch` that continues the flow after an auth or validation failure
90
+ - Broad `except Exception` / `catch (\Throwable)` / `recover()` that returns success or default-allow
91
+ - Missing `return` after an abort/short-circuit — execution continues past the intended stop
92
+
93
+ **Crypto.**
94
+ - Custom crypto primitives (hand-rolled HMAC, token generation) — flag on sight
95
+ - `md5` / `sha1` used for password hashing, session tokens, or signed URLs
96
+ - Non-constant-time comparison on tokens (`==` instead of `hash_equals` / `hmac.compare_digest` / `crypto.timingSafeEqual`)
97
+ - Predictable randomness (`rand()`, `mt_rand()`, `Math.random()`, `math/rand`) for security-sensitive values
98
+
99
+ ## Conditional Checks (fire only for the detected stack)
100
+
101
+ **If Laravel detected:**
102
+ - **Mass assignment**: `Model::create($request->all())`, `->update($request->all())`, `->fill($request->all())`, `->forceFill(...)` — flag. Want `$request->validated()` with a Form Request that enumerates allowed fields.
103
+ - **Missing `$fillable` / `$guarded`** on a new Eloquent model ever created from request data
104
+ - **`DB::raw` / `DB::statement` / `whereRaw` / `selectRaw`** with `$variable` interpolation. `DB::raw('NOW()')` is fine; `DB::raw("users.id = $id")` is not
105
+ - **Policy bypass**: controller action without `$this->authorize(...)` / `Gate::authorize(...)` where a matching Policy exists (grep `app/Policies/`)
106
+ - **Auth middleware**: new route file/group without `auth` / `auth:sanctum`; check `routes/web.php` + `routes/api.php`
107
+ - **Signed URLs**: `URL::temporarySignedRoute` without a short `expires`, or a handler that omits `$request->hasValidSignature()`
108
+ - **Queued jobs** that take a model ID and re-fetch without re-checking ownership/scope
109
+ - **Blade `{!! !!}`** rendering user-provided content without a sanitizer
110
+ - **File upload**: `->store()` / `->storeAs()` using a user-controlled filename — path traversal
111
+
112
+ **If Node / TypeScript detected:**
113
+ - Raw SQL via template literals (`` db.query(`... ${userInput} ...`) ``) instead of parameterized queries
114
+ - `child_process.exec`/`execSync` with interpolated input (prefer `execFile`/`spawn` with an arg array)
115
+ - `eval` / `new Function` / `vm.runInNewContext` on untrusted data
116
+ - Route handlers (Express/Nest/Fastify) without an auth middleware/guard, or `next()` called past a failed check
117
+ - Unsanitized user content into `dangerouslySetInnerHTML` (React) / `v-html` (Vue); SSR props over-exposing server state
118
+ - `jsonwebtoken` verify with `algorithms` unset or `none` allowed; missing `expiresIn`
119
+
120
+ **If Python detected:**
121
+ - `subprocess.*` with `shell=True` and interpolated input
122
+ - `pickle.loads` / `yaml.load` (unsafe) / `eval` / `exec` on untrusted data
123
+ - `requests` / `httpx` with `verify=False`
124
+ - SQLAlchemy raw `text(...)` with string interpolation
125
+ - JWT verification with `verify_signature=False` or `algorithms=["none"]`
126
+ - Pydantic input models missing `extra = "forbid"` (silent field drop can hide privilege escalation)
127
+
128
+ **If Go detected:**
129
+ - `fmt.Sprintf` building SQL passed to `db.Query`/`Exec` instead of placeholders
130
+ - `exec.Command("sh", "-c", interpolated)` — shell injection
131
+ - `html/template` vs `text/template` confusion when rendering to HTML
132
+ - Errors ignored (`val, _ := ...`) on an auth/validation path, or a missing `return` after `http.Error`
133
+
134
+ ## Calibrated Exclusions — Do Not Flag
135
+
136
+ 1. Patterns in test fixtures that are obvious test data (a dummy literal password in a unit test)
137
+ 2. Code paths unreachable without an existing separate auth bypass
138
+ 3. Rate-limiting concerns on internal admin endpoints behind a network boundary (VPN/private subnet)
139
+ 4. Hypothetical timing attacks on ms-granularity comparisons not involving secrets
140
+ 5. "Could be improved by using a secrets manager" when the code already uses env vars correctly
141
+
142
+ ## Output Format
143
+
144
+ ```
145
+ ## Stack detected
146
+ <one line — stack(s) + tenancy model (single-tenant / multi-tenant via <evidence>)>
147
+
148
+ ## Findings (Confidence ≥ 8)
149
+
150
+ ### 1. [Critical] <short title>
151
+ - File: `src/api/users.py:42-55`
152
+ - Confidence: 9/10
153
+ - Exploit: "An attacker with any authenticated account can send a PATCH to /users/{id} with an `is_admin` field in the body, which is passed straight to the ORM update, escalating their own account to admin — full privilege escalation."
154
+ - Fix: Replace the `**request.json` spread in `update_user()` with an explicit allow-list of mutable fields, and set privileged fields only from server-side state.
155
+
156
+ ## Lower confidence, worth a look
157
+ - `src/jobs/sync.py:28` — job takes resource_id from payload but does not re-verify ownership on re-fetch (C6 — likely fine because only the owning controller dispatches it, but confirm no other dispatcher exists)
158
+
159
+ ## Summary
160
+ <one sentence — e.g. "One Critical privilege-escalation. Do not merge.">
161
+ ```
162
+
163
+ If no findings at Confidence ≥ 8: "No findings at Confidence ≥ 8. Stack: <one line>." Stop.
@@ -66,7 +66,7 @@ suite_audit:
66
66
  e2e: N
67
67
  runners: ["playwright", "vitest"]
68
68
  findings:
69
- - category: flake | coverage_gap | anti_pattern | quarantine_candidate | ci_gap
69
+ - category: flake | coverage-gap | anti-pattern | quarantine-candidate | ci-gap
70
70
  file: "e2e/login.spec.ts"
71
71
  lines: "42-58"
72
72
  severity: critical | warning | info
@@ -141,12 +141,10 @@ Baked rules. Author mode follows all. Violations = rework.
141
141
  | Anti-Pattern | Mode | Why it's wrong |
142
142
  |--------------|------|----------------|
143
143
  | Writing source during audit | analyst | Read-only gate; fixes go through backlog + quick-tasking |
144
- | `sleep(1000)` or hardcoded waits | author | Masks race, creates flake |
145
144
  | Rubber-stamping suite as healthy | analyst | Defeats audit; real suites have findings |
146
145
  | Inventing YAML fields | analyst | Breaks testing skill aggregation |
147
146
  | Severity inflation / backlog spam | analyst | Degrades signal; reviewer bar applies |
148
147
  | Auto-fixing analyst findings | analyst | Violates deferred decision: no auto-queue analyst → author |
149
- | Shared page across tests | author | Leaks state; breaks test isolation |
150
- | CSS-selector locators over data-testid | author | Fragile; breaks on style refactor |
151
- | "Retry until pass" in CI | author | Hides flake; prefer quarantine |
152
148
  | Self-switching modes mid-run | either | Mode is set at spawn; exit cleanly and let skill re-spawn |
149
+
150
+ **Author-mode flake anti-patterns** (`sleep`/hardcoded waits, shared `page` across tests, CSS selectors over `data-testid`, "retry until pass") are the **Flake-Resistant Rules** above — violating any numbered rule is an anti-pattern. Not repeated here.
@@ -1,5 +1,44 @@
1
1
  # Forge Hooks
2
2
 
3
+ ## Safety Guardrails (ADR-017)
4
+
5
+ Deterministic, model-independent guardrails wired into `.claude/settings.json`.
6
+ They fire on every matching tool call regardless of what the model decides, so
7
+ they catch the destructive cases a model-driven skill (`securing`) misses when
8
+ skipped. All require `jq` and **fail closed** (deny) if it is missing — except
9
+ `format-on-save`, which fails open so formatting never blocks work.
10
+
11
+ | Hook | Event · matcher | Blocks |
12
+ |---|---|---|
13
+ | `block-dangerous-commands.sh` | PreToolUse · Bash | force push, push to a protected branch, `rm -rf` on `/`/`~`/`$VAR`/system dirs/substituted targets, `DROP`/`TRUNCATE`/`DELETE`-without-WHERE, destructive migrations (`migrate:fresh`, `db:wipe`, `rails db:reset`, `prisma migrate reset`, …), `chmod 777`, `curl\|sh`, `dd`/`mkfs` on devices, `git reset --hard`, `git clean -f`, accidental `npm/cargo/gem/twine/uv/poetry/composer publish` |
14
+ | `scan-secrets.sh` | PreToolUse · Edit\|Write | content containing AWS/GitHub/Anthropic/Slack/Stripe/Google keys, private-key blocks, credentialed connection strings, Laravel `APP_KEY`, generic hardcoded credentials — decision `ask` so genuine fixtures can be overridden |
15
+ | `protect-files.sh` | PreToolUse · Edit\|Write | edits to `.env`, keys/certs, lockfiles, generated/minified files, `.git/`, `secrets/`; `ask` on `settings.json`. Allowlists `.env.example`/`.sample`/`.dist`/`.template` |
16
+ | `warn-large-files.sh` | PreToolUse · Edit\|Write | writes into `node_modules`/`vendor`/build output/tool caches and binary/archive/media files |
17
+ | `format-on-save.sh` | PostToolUse · Edit\|Write\|MultiEdit | (not a guard) runs the project's formatter — ruff/black, pint, prettier/eslint/biome, rustfmt, gofmt, shfmt — only when the matching project config is present |
18
+
19
+ ### Configuration
20
+
21
+ - `CLAUDE_PROTECTED_BRANCHES` — comma list of branches `block-dangerous-commands`
22
+ refuses to push to (default `main,master` + `git init.defaultBranch`). Set to
23
+ `""` (empty) to disable the protected-branch push check only; force-push, fs,
24
+ db, and publish guards still apply. (Forge's own dev repo sets this empty —
25
+ framework work legitimately lands on `main`.)
26
+ - `FORGE_ALLOW_HOOK_EDITS` — set to `1` to let `protect-files` allow edits to
27
+ `.claude/hooks/` and `settings.json`. Off by default (projects re-sync hooks
28
+ via `/upgrading`); on in the Forge framework repo, where authoring them is the work.
29
+
30
+ ### Tests
31
+
32
+ `.claude/hooks/tests/run.sh` runs the JSON-case suite in `tests/cases/`. Each
33
+ case pipes an input to a hook and asserts exit code + stdout. Run after any hook
34
+ change; add a deny/allow/no-op case per new rule. See `tests/README.md`.
35
+
36
+ ### Install
37
+
38
+ The `create-forge` template ships these enabled. After copying, make them
39
+ executable: `chmod +x .claude/hooks/*.sh`. Without `jq` the guard hooks block
40
+ with an install message — `brew install jq` (macOS) / `apt install jq` (Linux).
41
+
3
42
  ## `forge-claim-check.sh` — PreToolUse claim-check
4
43
 
5
44
  Cross-session file-claim collision detector. Pairs with the Forge MCP
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # Forge safety guardrail — blocks destructive shell commands.
3
+ # PreToolUse hook for Bash. Exit 2 = block. Exit 0 = allow.
4
+ #
5
+ # Deterministic belt-and-braces: this fires regardless of what the model decides,
6
+ # so it catches the cases a model-driven skill (securing) would miss when skipped.
7
+ #
8
+ # Configurable via env:
9
+ # CLAUDE_PROTECTED_BRANCHES comma list of branches push is refused to.
10
+ # Default: main,master + git init.defaultBranch.
11
+ # Set to "" (empty) to DISABLE the protected-branch
12
+ # push check only — force-push, fs, db, and publish
13
+ # guards still apply. Forge's own dev repo does this
14
+ # because framework work legitimately lands on main.
15
+
16
+ set -uo pipefail
17
+
18
+ emit_deny() {
19
+ local reason="${1//\"/\\\"}"
20
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
21
+ exit 2
22
+ }
23
+
24
+ if ! command -v jq >/dev/null 2>&1; then
25
+ emit_deny "jq is required for command protection hooks but is not installed. Install with: brew install jq (macOS) or apt install jq (Linux)."
26
+ fi
27
+
28
+ INPUT=$(cat)
29
+ COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
30
+ [ -z "$COMMAND" ] && exit 0
31
+
32
+ contains_cmd() { printf '%s' "$COMMAND" | grep -qE "$1"; }
33
+ contains_icmd() { printf '%s' "$COMMAND" | grep -qiE "$1"; }
34
+
35
+ # ── Protected branch list ────────────────────────────────────────────────
36
+ # Use ${VAR-default} (no colon) so an explicitly-empty value disables the check
37
+ # while an unset value falls back to the default.
38
+ DEFAULT_BRANCHES="main,master"
39
+ if GIT_DEFAULT=$(git config --get init.defaultBranch 2>/dev/null) && [ -n "$GIT_DEFAULT" ]; then
40
+ DEFAULT_BRANCHES="$DEFAULT_BRANCHES,$GIT_DEFAULT"
41
+ fi
42
+ PROTECTED_BRANCHES="${CLAUDE_PROTECTED_BRANCHES-$DEFAULT_BRANCHES}"
43
+
44
+ # ── Git push protections ────────────────────────────────────────────────
45
+ if contains_cmd '(^|[;&|()]+[[:space:]]*)git[[:space:]]+push'; then
46
+ # Protected-branch checks are skipped when the list is empty.
47
+ if [ -n "$PROTECTED_BRANCHES" ]; then
48
+ BR_REGEX=$(printf '%s' "$PROTECTED_BRANCHES" | tr ',' '\n' | awk 'NF{printf "%s%s",sep,$0; sep="|"}')
49
+ # Explicit refspec to a protected branch (origin main, :main, HEAD:main)
50
+ if contains_cmd "git[[:space:]]+push[[:space:]]+[^[:space:]]+[[:space:]]+([^[:space:]]*:)?($BR_REGEX)(\$|[[:space:]])"; then
51
+ MATCHED_BRANCH=$(printf '%s' "$COMMAND" | grep -oE "($BR_REGEX)(\$|[[:space:]])" | head -1 | tr -d '[:space:]')
52
+ emit_deny "Blocked: push to protected branch '${MATCHED_BRANCH:-main}'. Use a feature branch and open a PR."
53
+ fi
54
+ if contains_cmd "git[[:space:]]+push.*:($BR_REGEX)(\$|[[:space:]])"; then
55
+ MATCHED_BRANCH=$(printf '%s' "$COMMAND" | grep -oE ":($BR_REGEX)(\$|[[:space:]])" | head -1 | tr -d ': [:space:]')
56
+ emit_deny "Blocked: push to protected branch '${MATCHED_BRANCH:-main}' via refspec. Use a feature branch and open a PR."
57
+ fi
58
+ # Bare `git push` while on protected branch
59
+ if contains_cmd 'git[[:space:]]+push[[:space:]]*($|[;&|])'; then
60
+ CURRENT=$(git branch --show-current 2>/dev/null || true)
61
+ if [ -n "$CURRENT" ] && printf '%s' ",$PROTECTED_BRANCHES," | grep -q ",$CURRENT,"; then
62
+ emit_deny "Blocked: you are on '$CURRENT' (a protected branch). Switch to a feature branch."
63
+ fi
64
+ fi
65
+ fi
66
+ # Force push (but allow --force-with-lease). Always enforced — Forge never force-pushes.
67
+ if contains_cmd 'git[[:space:]]+push([[:space:]]+[^[:space:]]+)*[[:space:]]+(-[a-zA-Z]*f[a-zA-Z]*|--force)([[:space:]=]|$)' \
68
+ && ! contains_cmd '\-\-force-with-lease'; then
69
+ emit_deny "Blocked: force push is not allowed. Use --force-with-lease if you must overwrite remote."
70
+ fi
71
+ fi
72
+
73
+ # ── Destructive filesystem operations ───────────────────────────────────
74
+ # rm -rf targeting root, home, $HOME, $VAR (any unresolved expansion), or parent traversal.
75
+ CMD_NOQUOTE=$(printf '%s' "$COMMAND" | tr -d "'\"")
76
+ if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]*[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+(/([[:space:]]|\*|$)|~|\$HOME|\$[A-Za-z_][A-Za-z0-9_]*|\.\./\.\.)' ; then
77
+ emit_deny "Blocked: recursive force-delete on /, ~, \$HOME, an unresolved \$VAR, or .../.. path. Specify a concrete safe target."
78
+ fi
79
+ # rm -rf /usr, /etc, /var, /bin, etc.
80
+ if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+/(usr|etc|var|bin|sbin|lib|opt|root|boot)([[:space:]/]|$)'; then
81
+ emit_deny "Blocked: recursive delete targeting a system directory."
82
+ fi
83
+ # rm -rf where the target is produced by $(...) or `...` command substitution.
84
+ if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]*[[:space:]]+)*-?[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*[[:space:]]+[^;&|]*(\$\(|`)'; then
85
+ emit_deny "Blocked: recursive force-delete with a shell-substituted target (\$(...) or \`...\`). Pass a literal path."
86
+ fi
87
+
88
+ # ── Dangerous database operations ───────────────────────────────────────
89
+ if contains_icmd 'DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)[[:space:]]+'; then
90
+ emit_deny "Blocked: DROP TABLE/DATABASE/SCHEMA detected. Run manually if intended."
91
+ fi
92
+ # DELETE FROM without a WHERE on the SAME statement (split on ';').
93
+ if printf '%s\n' "$COMMAND" | awk '
94
+ BEGIN { IGNORECASE=1; RS=";" }
95
+ /DELETE[[:space:]]+FROM[[:space:]]+[A-Za-z_][A-Za-z0-9_.]*/ {
96
+ if ($0 !~ /WHERE/) { print "BAD"; exit }
97
+ }
98
+ ' | grep -q BAD; then
99
+ emit_deny "Blocked: DELETE FROM without a WHERE clause. Add a WHERE or run manually."
100
+ fi
101
+ if contains_icmd 'TRUNCATE[[:space:]]+TABLE'; then
102
+ emit_deny "Blocked: TRUNCATE TABLE detected. Run manually if intended."
103
+ fi
104
+
105
+ # ── Framework-specific destructive migration commands ───────────────────
106
+ # These drop data. Block — require manual invocation against a dev DB.
107
+ if contains_icmd '(php[[:space:]]+artisan[[:space:]]+(migrate:fresh|migrate:reset|migrate:refresh|db:wipe)|(rails|rake)[[:space:]]+db:(drop|reset)|prisma[[:space:]]+migrate[[:space:]]+reset|sequelize[[:space:]]+db:drop)([[:space:]]|$)'; then
108
+ emit_deny "Blocked: destructive migration command detected (drops/recreates the database). Run manually, and only against a dev DB."
109
+ fi
110
+
111
+ # ── Dangerous system commands ───────────────────────────────────────────
112
+ # chmod: any world-writable/universal mode (0?777 or a+rwx)
113
+ if contains_cmd 'chmod([[:space:]]+-[a-zA-Z]+)*[[:space:]]+0?777([[:space:]]|$)' \
114
+ || contains_cmd 'chmod([[:space:]]+-[a-zA-Z]+)*[[:space:]]+a\+rwx([[:space:]]|$)'; then
115
+ emit_deny "Blocked: chmod 777 / a+rwx grants everyone full access. Use restrictive perms."
116
+ fi
117
+
118
+ # curl/wget piped to a shell
119
+ if contains_cmd '(curl|wget)[[:space:]].*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|ksh|fish|dash|csh)([[:space:]]|$)'; then
120
+ emit_deny "Blocked: piping downloaded content directly to a shell is dangerous."
121
+ fi
122
+
123
+ # Disk / partition. Only REDIRECTIONS to /dev/ are destructive — `2>/dev/null` is not.
124
+ if printf '%s' "$COMMAND" | grep -qE '(^|[^0-9&])>[[:space:]]*/dev/[a-zA-Z][a-zA-Z0-9]*' \
125
+ && ! printf '%s' "$COMMAND" | grep -qE '>[[:space:]]*/dev/(null|stdout|stderr|tty|zero|random|urandom)([[:space:]]|$)' ; then
126
+ emit_deny "Blocked: redirection into a raw device file can destroy data."
127
+ fi
128
+ if contains_cmd '(^|[;&|[:space:]])(mkfs|mkfs\.[a-z0-9]+)([[:space:]]|$)' \
129
+ || contains_cmd '(^|[;&|[:space:]])dd[[:space:]]+[^|]*(if|of)=/dev/[a-zA-Z]' ; then
130
+ emit_deny "Blocked: mkfs/dd against a device node. Irreversible data loss."
131
+ fi
132
+
133
+ # ── Destructive git ─────────────────────────────────────────────────────
134
+ if contains_cmd 'git[[:space:]]+reset[[:space:]]+--hard'; then
135
+ emit_deny "Blocked: git reset --hard discards uncommitted changes permanently. Use git stash or commit first."
136
+ fi
137
+ if contains_cmd 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
138
+ emit_deny "Blocked: git clean -f permanently deletes untracked files."
139
+ fi
140
+
141
+ # ── Accidental package publishing ───────────────────────────────────────
142
+ # Allow --dry-run variants. Covers the common package managers.
143
+ publish_patterns=(
144
+ '(npm|yarn|pnpm|bun)[[:space:]]+publish'
145
+ 'cargo[[:space:]]+publish'
146
+ 'gem[[:space:]]+push'
147
+ 'twine[[:space:]]+upload'
148
+ 'uv[[:space:]]+publish'
149
+ 'poetry[[:space:]]+publish'
150
+ 'composer[[:space:]]+publish'
151
+ )
152
+ for pat in "${publish_patterns[@]}"; do
153
+ if contains_cmd "$pat" && ! contains_cmd '(^|[[:space:]])(--dry-run|-n)([[:space:]=]|$)'; then
154
+ emit_deny "Blocked: publishing packages should run in CI or be done manually, not via the agent."
155
+ fi
156
+ done
157
+
158
+ exit 0
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # Forge PreToolUse(Write|Edit) workflow guard.
3
+ #
4
+ # Blocks code edits when no Forge skill is driving the session, nudging the
5
+ # operator to invoke /forge or /quick-tasking first. A skill IS active when
6
+ # EITHER signal holds:
7
+ #
8
+ # (a) the ephemeral marker .forge/.active-skill exists — written by the
9
+ # PostToolUse(Skill) hook on every Skill invocation; reliable for short
10
+ # skills (quick-tasking) but cleared between turns / at session
11
+ # boundaries, so a long executing run loses it mid-flight; OR
12
+ # (b) a milestone is mid-execution (current.status: executing) — the durable
13
+ # signal that survives across turns because it lives in a committed state
14
+ # file, not an ephemeral marker.
15
+ #
16
+ # (b) is the fix for the failure mode where the marker was cleared 3+ times in
17
+ # one executing session and legitimate edits got blocked (forge#12). When (b)
18
+ # holds we also re-assert the marker so the rest of the turn is cheap.
19
+ #
20
+ # Contract: exit 0 = allow, exit 2 = block with guidance. Advisory workflow
21
+ # gate — the documented bypass is `touch .forge/.active-skill`.
22
+
23
+ set -uo pipefail
24
+
25
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
26
+ [ -n "$ROOT" ] || ROOT="$PWD"
27
+ FORGE="$ROOT/.forge"
28
+
29
+ # (a) marker present → active.
30
+ [ -f "$FORGE/.active-skill" ] && exit 0
31
+
32
+ # (b) a milestone is executing → durable "skill active" signal that outlives the
33
+ # marker. Match the indented `current.status: executing` line; a milestone
34
+ # file is small, so the grep is cheap.
35
+ if ls "$FORGE"/state/milestone-*.yml >/dev/null 2>&1; then
36
+ if grep -qE '^[[:space:]]+status:[[:space:]]*executing([[:space:]]|$)' "$FORGE"/state/milestone-*.yml 2>/dev/null; then
37
+ # Self-heal: re-assert the marker so later checks this turn short-circuit at (a).
38
+ printf 'executing\n' > "$FORGE/.active-skill" 2>/dev/null || true
39
+ exit 0
40
+ fi
41
+ fi
42
+
43
+ echo "[Forge] No active skill. Invoke /forge or /quick-tasking before editing code. To bypass: touch .forge/.active-skill" >&2
44
+ exit 2