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.
- package/bin/create-forge.js +245 -43
- package/package.json +5 -1
- package/template/.claude/agents/doc-reviewer.md +115 -0
- package/template/.claude/agents/performance-reviewer.md +138 -0
- package/template/.claude/agents/security-reviewer.md +163 -0
- package/template/.claude/agents/tester.md +3 -5
- package/template/.claude/hooks/README.md +39 -0
- package/template/.claude/hooks/block-dangerous-commands.sh +158 -0
- package/template/.claude/hooks/forge-active-skill-guard.sh +44 -0
- package/template/.claude/hooks/format-on-save.sh +95 -0
- package/template/.claude/hooks/protect-files.sh +101 -0
- package/template/.claude/hooks/scan-secrets.sh +87 -0
- package/template/.claude/hooks/tests/README.md +76 -0
- package/template/.claude/hooks/tests/cases/block-dangerous-commands.cases.json +376 -0
- package/template/.claude/hooks/tests/cases/protect-files.cases.json +222 -0
- package/template/.claude/hooks/tests/cases/scan-secrets.cases.json +218 -0
- package/template/.claude/hooks/tests/cases/warn-large-files.cases.json +146 -0
- package/template/.claude/hooks/tests/run.sh +118 -0
- package/template/.claude/hooks/warn-large-files.sh +71 -0
- package/template/.claude/rules/README.md +63 -0
- package/template/.claude/rules/agent-discipline.md +14 -0
- package/template/.claude/settings.json +69 -1
- package/template/.claude/skills/architecting/SKILL.md +2 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +37 -26
- package/template/.claude/skills/executing/SKILL.md +16 -0
- package/template/.claude/skills/forge/SKILL.md +15 -27
- package/template/.claude/skills/forge/desire-paths-review.md +37 -0
- package/template/.claude/skills/initializing/SKILL.md +4 -0
- package/template/.claude/skills/planning/SKILL.md +17 -3
- package/template/.claude/skills/reviewing/SKILL.md +2 -0
- package/template/.claude/skills/testing/SKILL.md +3 -3
- package/template/.claude/skills/verifying/SKILL.md +20 -9
- package/template/.forge/FORGE.md +36 -8
- package/template/.forge/migrations/0.20.0-nested-phase-layout.md +10 -2
- package/template/.forge/migrations/0.42.0-id-reservation.md +48 -0
- package/template/.forge/migrations/0.43.0-safety-policy.md +60 -0
- package/template/.forge/migrations/0.44.0-desire-paths.md +57 -0
- 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 |
|
|
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
|