voidforge-build 23.10.0 → 23.11.1
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/dist/.claude/agents/bashir-field-medic.md +1 -0
- package/dist/.claude/agents/coulson-release.md +3 -0
- package/dist/.claude/agents/irulan-historian.md +3 -0
- package/dist/.claude/agents/loki-chaos.md +1 -0
- package/dist/.claude/agents/picard-architecture.md +3 -0
- package/dist/.claude/agents/silver-surfer-herald.md +3 -0
- package/dist/.claude/agents/sisko-campaign.md +3 -0
- package/dist/.claude/commands/architect.md +38 -0
- package/dist/.claude/commands/campaign.md +2 -0
- package/dist/.claude/commands/gauntlet.md +11 -0
- package/dist/.claude/commands/git.md +49 -6
- package/dist/CHANGELOG.md +84 -0
- package/dist/CLAUDE.md +13 -4
- package/dist/VERSION.md +3 -1
- package/dist/docs/methods/AI_INTELLIGENCE.md +15 -0
- package/dist/docs/methods/BACKEND_ENGINEER.md +48 -0
- package/dist/docs/methods/CAMPAIGN.md +196 -1
- package/dist/docs/methods/DEVOPS_ENGINEER.md +16 -0
- package/dist/docs/methods/FORGE_KEEPER.md +18 -0
- package/dist/docs/methods/GAUNTLET.md +2 -0
- package/dist/docs/methods/QA_ENGINEER.md +46 -0
- package/dist/docs/methods/RELEASE_MANAGER.md +85 -0
- package/dist/docs/methods/SECURITY_AUDITOR.md +53 -0
- package/dist/docs/methods/SUB_AGENTS.md +90 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +42 -2
- package/dist/docs/methods/TESTING.md +17 -0
- package/dist/docs/methods/TIME_VAULT.md +17 -0
- package/dist/docs/patterns/adr-verification-gate.md +80 -0
- package/dist/docs/patterns/ai-eval.ts +87 -0
- package/dist/docs/patterns/ai-prompt-safety.ts +242 -0
- package/dist/docs/patterns/audit-log.ts +132 -0
- package/dist/docs/patterns/llm-state-dedup.ts +246 -0
- package/dist/docs/patterns/middleware.ts +83 -0
- package/dist/docs/patterns/multi-tenant-pool-bypass.ts +134 -0
- package/dist/docs/patterns/multi-tenant-property-test.ts +127 -0
- package/dist/docs/patterns/refactor-extraction.md +96 -0
- package/dist/wizard/lib/project-init.js +57 -0
- package/package.json +1 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Multi-Tenant Property Test
|
|
3
|
+
*
|
|
4
|
+
* Source: Field report #315 M4 (Caroline first-user-test, 2026-03-31).
|
|
5
|
+
* Caroline found 10 multi-tenant bugs that prior gauntlets missed because
|
|
6
|
+
* regression tests lock known cases — they don't test the underlying
|
|
7
|
+
* property: "for any two orgs A and B, A's writes never appear in B's reads."
|
|
8
|
+
*
|
|
9
|
+
* This pattern provides the property-based test that closes the gap. Use it
|
|
10
|
+
* on every project with org_id (or tenant_id, workspace_id) scoping.
|
|
11
|
+
*
|
|
12
|
+
* The TS version below is illustrative (vitest + fast-check). Python
|
|
13
|
+
* (Hypothesis) and Go variants follow the same shape — generate random
|
|
14
|
+
* org pairs and write payloads, assert no cross-tenant leak.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
18
|
+
import fc from 'fast-check';
|
|
19
|
+
|
|
20
|
+
// ── Test harness contract ────────────────────────────────────────────────
|
|
21
|
+
//
|
|
22
|
+
// The harness must provide:
|
|
23
|
+
// - createOrg() → { id, apiKey, userId } (fresh tenant per call)
|
|
24
|
+
// - writeAsOrg(org, endpoint, payload) (authenticated POST/PUT)
|
|
25
|
+
// - readAsOrg(org, endpoint) (authenticated GET, paginated)
|
|
26
|
+
// - listAllReadEndpoints() → string[] (every GET that returns rows)
|
|
27
|
+
// - listAllWriteEndpoints() → string[] (every POST/PUT/DELETE)
|
|
28
|
+
// - resetDb() (drop + reseed schema)
|
|
29
|
+
|
|
30
|
+
declare const harness: {
|
|
31
|
+
createOrg(): Promise<{ id: number; apiKey: string; userId: string }>;
|
|
32
|
+
writeAsOrg(org: { apiKey: string }, endpoint: string, payload: unknown): Promise<{ id: string }>;
|
|
33
|
+
readAsOrg(org: { apiKey: string }, endpoint: string): Promise<Array<{ id: string; org_id?: number }>>;
|
|
34
|
+
listAllReadEndpoints(): string[];
|
|
35
|
+
listAllWriteEndpoints(): string[];
|
|
36
|
+
resetDb(): Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ── The Property ─────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe('multi-tenant isolation property', () => {
|
|
42
|
+
beforeEach(async () => harness.resetDb());
|
|
43
|
+
|
|
44
|
+
test('writes by org A never appear in reads by org B', async () => {
|
|
45
|
+
await fc.assert(
|
|
46
|
+
fc.asyncProperty(
|
|
47
|
+
// Random pair of orgs (always distinct)
|
|
48
|
+
fc.tuple(fc.constant(null), fc.constant(null)),
|
|
49
|
+
// Random write endpoint
|
|
50
|
+
fc.constantFrom(...harness.listAllWriteEndpoints()),
|
|
51
|
+
// Random payload — your codebase's payload generator goes here
|
|
52
|
+
randomPayload(),
|
|
53
|
+
async (_pair, writeEndpoint, payload) => {
|
|
54
|
+
const orgA = await harness.createOrg();
|
|
55
|
+
const orgB = await harness.createOrg();
|
|
56
|
+
|
|
57
|
+
// 1. Org A writes
|
|
58
|
+
const written = await harness.writeAsOrg(orgA, writeEndpoint, payload);
|
|
59
|
+
|
|
60
|
+
// 2. Every read endpoint, queried as Org B, must NOT contain the write
|
|
61
|
+
for (const readEndpoint of harness.listAllReadEndpoints()) {
|
|
62
|
+
const rowsB = await harness.readAsOrg(orgB, readEndpoint);
|
|
63
|
+
const leaked = rowsB.find((row) => row.id === written.id);
|
|
64
|
+
if (leaked) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`LEAK: ${writeEndpoint} write by org ${orgA.id} surfaced in ` +
|
|
67
|
+
`${readEndpoint} read by org ${orgB.id}. Row: ${JSON.stringify(leaked)}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
),
|
|
73
|
+
{ numRuns: 100, timeout: 60_000 },
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('superuser/admin pool acquisition does NOT bypass per-org reads', async () => {
|
|
78
|
+
// Companion property: admin-pool callers (cross-tenant by design) must
|
|
79
|
+
// still respect org_id when calling tenant endpoints. Field report #318
|
|
80
|
+
// §5: SUPERUSER + BYPASSRLS=t hides policy bugs. Test under non-owner role.
|
|
81
|
+
const orgA = await harness.createOrg();
|
|
82
|
+
const orgB = await harness.createOrg();
|
|
83
|
+
|
|
84
|
+
await harness.writeAsOrg(orgA, '/api/people', { name: 'A1' });
|
|
85
|
+
const rowsB = await harness.readAsOrg(orgB, '/api/people');
|
|
86
|
+
expect(rowsB.find((r) => r.org_id === orgA.id)).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function randomPayload(): fc.Arbitrary<unknown> {
|
|
91
|
+
// Generic structure — narrow per-endpoint in real implementations.
|
|
92
|
+
return fc.record({
|
|
93
|
+
name: fc.string({ minLength: 1, maxLength: 50 }),
|
|
94
|
+
note: fc.option(fc.string({ maxLength: 200 })),
|
|
95
|
+
tags: fc.array(fc.string(), { maxLength: 5 }),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Python (Hypothesis) sketch ───────────────────────────────────────────
|
|
100
|
+
//
|
|
101
|
+
// from hypothesis import given, strategies as st, settings
|
|
102
|
+
//
|
|
103
|
+
// @given(write_endpoint=st.sampled_from(WRITE_ENDPOINTS),
|
|
104
|
+
// payload=payload_strategy())
|
|
105
|
+
// @settings(max_examples=100, deadline=None)
|
|
106
|
+
// def test_no_cross_tenant_leak(write_endpoint, payload):
|
|
107
|
+
// reset_db()
|
|
108
|
+
// org_a, org_b = create_org(), create_org()
|
|
109
|
+
// written = write_as_org(org_a, write_endpoint, payload)
|
|
110
|
+
// for read_endpoint in READ_ENDPOINTS:
|
|
111
|
+
// rows_b = read_as_org(org_b, read_endpoint)
|
|
112
|
+
// assert not any(r['id'] == written['id'] for r in rows_b), \
|
|
113
|
+
// f"LEAK: {write_endpoint} -> {read_endpoint}"
|
|
114
|
+
//
|
|
115
|
+
// ── Anti-patterns ────────────────────────────────────────────────────────
|
|
116
|
+
//
|
|
117
|
+
// 1. Testing isolation only on known endpoints. The bug is in the endpoint
|
|
118
|
+
// you forgot. Property tests enumerate the full surface.
|
|
119
|
+
// 2. Using SUPERUSER fixtures. They silently bypass FORCE RLS at the engine
|
|
120
|
+
// level. Use the runtime non-owner role (`{project}_app`, BYPASSRLS=f).
|
|
121
|
+
// See /docs/patterns/rls-test-fixture.py.
|
|
122
|
+
// 3. Locking the property to "100% pass" without expanding the endpoint
|
|
123
|
+
// list as the codebase grows. listAll{Read,Write}Endpoints() must be
|
|
124
|
+
// derived dynamically (route enumeration, not hardcoded).
|
|
125
|
+
// 4. Testing only "row id leaks." Add field-level checks for any column
|
|
126
|
+
// holding semi-sensitive data (emails, internal notes) — leaks of
|
|
127
|
+
// *content* without row visibility are equally bad.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Pattern: Large-Refactor Extraction (8-commit per-entity)
|
|
2
|
+
|
|
3
|
+
**When to use:** A 1,000+ LOC router/service/handler file needs splitting and the project has an existing exit gate (e.g., max-LOC-per-file). Single-commit refactors of files this size cause review fatigue, hide bugs in the diff noise, and are nearly impossible to revert surgically.
|
|
4
|
+
|
|
5
|
+
**Source:** Field report #320 §1. M-10 (Union Station): `routers/crm.py` 1,861 → 597 LOC across 8 commits. 0 regressions. Test count grew 2,099 → 2,246 (+147). Exit gate met with 78 LOC of headroom. The 5th commit's IDOR matrix surfaced a route-shadow bug that had made `PATCH /people/batch-update` unreachable in production for an unknown duration.
|
|
6
|
+
|
|
7
|
+
This is the cleanest large-refactor template I've documented. Use it.
|
|
8
|
+
|
|
9
|
+
## Architecture-Quick (Picard)
|
|
10
|
+
|
|
11
|
+
Before any commit, write a 1-2 page architecture doc to `logs/reviews/<topic>-architecture.md`:
|
|
12
|
+
|
|
13
|
+
```markdown
|
|
14
|
+
# Refactor: <topic> — extraction plan
|
|
15
|
+
|
|
16
|
+
## Current state
|
|
17
|
+
- Source file: <path> at <LOC>
|
|
18
|
+
- Exit gate: <LOC limit>
|
|
19
|
+
- LOC delta needed: <gate - current>
|
|
20
|
+
|
|
21
|
+
## Entity inventory
|
|
22
|
+
| Entity | Endpoints | Estimated LOC delta |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| people | 7 | -167 |
|
|
25
|
+
| companies | 10 | -345 |
|
|
26
|
+
| ... | ... | ... |
|
|
27
|
+
| | **Total** | **−1264** |
|
|
28
|
+
|
|
29
|
+
## Commit plan (one per entity + scaffold + cleanup)
|
|
30
|
+
| # | Commit | Adds | Removes | Cumulative LOC |
|
|
31
|
+
|---|---|---|---|---|
|
|
32
|
+
| 1 | scaffold (service base, error types, shared helpers) | services/_base.py | — | 1861 |
|
|
33
|
+
| 2 | extract people | services/people_service.py | router code | 1694 |
|
|
34
|
+
| ... | ... | ... | ... | ... |
|
|
35
|
+
| 8 | cleanup (lift duplicated helpers, prune imports, lint) | — | router cleanup | 597 |
|
|
36
|
+
|
|
37
|
+
## Function-signature contract (per service)
|
|
38
|
+
- org_id: int (first), user_id: str (second)
|
|
39
|
+
- Returns plain dict (no FastAPI Response wrappers)
|
|
40
|
+
- Raises ApiError (no HTTPException — service knows nothing of HTTP)
|
|
41
|
+
- No FastAPI imports in service modules
|
|
42
|
+
|
|
43
|
+
## Roles
|
|
44
|
+
- Strange — lead, owns sequencing
|
|
45
|
+
- Stark — router-side rewrites (thin wrappers calling service)
|
|
46
|
+
- Batgirl — IDOR matrix tests per entity
|
|
47
|
+
- Coulson — version + commit per step
|
|
48
|
+
|
|
49
|
+
## IDOR contract
|
|
50
|
+
- Pattern A (primary): every service method takes org_id as first param,
|
|
51
|
+
every query is scoped, every test in matrix asserts cross-org denial
|
|
52
|
+
- Pattern B (fallback): if a method legitimately spans tenants, document
|
|
53
|
+
the policy and test cross-tenant authorization explicitly
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Per-Commit Shape
|
|
57
|
+
|
|
58
|
+
Each entity commit follows the same shape. Keep them mechanically uniform:
|
|
59
|
+
|
|
60
|
+
1. **Extract** to `services/<entity>_service.py` — pure business logic, no FastAPI imports
|
|
61
|
+
2. **Rewrite** the router file as thin wrappers: validate → call service → format response
|
|
62
|
+
3. **Add** IDOR matrix tests for parametric paths AND fixed-suffix paths under same entity prefix (see `/docs/methods/SECURITY_AUDITOR.md` IDOR Matrix section)
|
|
63
|
+
4. **Verify** LOC trajectory: `git diff --stat HEAD~1 -- routers/<file>.py` shows monotonic decrease; service module count grows by 1
|
|
64
|
+
5. **Run targeted pytest** on touched files only (`pytest tests/services/test_<entity>_service.py tests/routers/test_<entity>.py`) — full suite is the orchestrator's gate, not the agent's
|
|
65
|
+
6. **Commit** with a "Deviations from Contract" section in the build report (see SUB_AGENTS.md)
|
|
66
|
+
|
|
67
|
+
## Final Cleanup Commit
|
|
68
|
+
|
|
69
|
+
Commit 8 (or N for an N-entity refactor) is non-obvious and load-bearing:
|
|
70
|
+
|
|
71
|
+
- Lift duplicated helpers that emerged across entities into a shared module
|
|
72
|
+
- Prune unused imports in the router file (extraction leaves behind imports the wrappers no longer need)
|
|
73
|
+
- Add lint scaffold if missing (LOC limit, signature-contract assertion)
|
|
74
|
+
- Verify no test files were dropped (mock paths often need updating to follow the extracted code)
|
|
75
|
+
- Confirm exit gate met with documented headroom: `wc -l routers/<file>.py`
|
|
76
|
+
|
|
77
|
+
## What This Pattern Caught
|
|
78
|
+
|
|
79
|
+
The IDOR matrix test in commit 5 (M-10 batch.py) surfaced that `/people/{person_id}` was shadowing `/people/batch-update`. FastAPI dispatches first-matching-route; a parametric path declared first eats subsequent fixed-suffix paths. The fix is path-converter type hints (`{person_id:int}`), restricting the parametric route to integer paths.
|
|
80
|
+
|
|
81
|
+
This bug had been latent in production. No unit test exercised it. No previous Gauntlet caught it. Without the IDOR matrix discipline this pattern bakes in, it would still be unreachable. (Field report #320 §1.)
|
|
82
|
+
|
|
83
|
+
## Anti-Patterns
|
|
84
|
+
|
|
85
|
+
- **Single-commit refactor** for files >1,000 LOC. Review fatigue + impossible to revert surgically.
|
|
86
|
+
- **No architecture-quick.** Without Picard's plan, the LOC trajectory drifts and entities get extracted in dependency-violating order.
|
|
87
|
+
- **No IDOR matrix.** Refactoring multi-tenant code without cross-tenant denial tests is just rearranging the leak surface.
|
|
88
|
+
- **Mixing entity extractions in one commit.** Each commit must remain shippable independently with green tests. One commit per entity, no exceptions.
|
|
89
|
+
- **Skipping the final cleanup commit.** Duplicated helpers that emerged across entities don't lift themselves; pruning matters.
|
|
90
|
+
- **Running full pytest as the agent's last step.** See SUB_AGENTS.md "Build-Agent Pytest Sequencing" — agent response window truncates mid-suite, orchestrator has to reconstruct.
|
|
91
|
+
|
|
92
|
+
## When NOT to Use
|
|
93
|
+
|
|
94
|
+
- File is under ~600 LOC. Just split it in one commit; the overhead isn't worth it.
|
|
95
|
+
- The file is genuinely cohesive (state machine, single algorithm, generated code). Extraction would fragment what should stay together.
|
|
96
|
+
- The exit gate isn't binding — if there's no LOC limit and no clear quality reason to split, the refactor is yak-shaving.
|
|
@@ -113,8 +113,65 @@ async function copyMethodology(methodologyRoot, projectDir, core) {
|
|
|
113
113
|
if (existsSync(thumperSrc)) {
|
|
114
114
|
count += await copyDir(thumperSrc, join(projectDir, 'scripts', 'thumper'));
|
|
115
115
|
}
|
|
116
|
+
// Surfer-gate scripts (ADR-051 enforcement — closes #317).
|
|
117
|
+
// Ship the gate to every new project so the hook can mechanically enforce
|
|
118
|
+
// CLAUDE.md's Silver Surfer procedure. Without this the prose-backstop is
|
|
119
|
+
// the only thing holding the line in consumer installs.
|
|
120
|
+
const surferGateSrc = join(methodologyRoot, 'scripts', 'surfer-gate');
|
|
121
|
+
if (existsSync(surferGateSrc)) {
|
|
122
|
+
count += await copyDir(surferGateSrc, join(projectDir, 'scripts', 'surfer-gate'));
|
|
123
|
+
await chmodShellScripts(join(projectDir, 'scripts', 'surfer-gate'));
|
|
124
|
+
await mergeSettingsHook(projectDir);
|
|
125
|
+
}
|
|
116
126
|
return count;
|
|
117
127
|
}
|
|
128
|
+
async function chmodShellScripts(dir) {
|
|
129
|
+
if (!existsSync(dir))
|
|
130
|
+
return;
|
|
131
|
+
const { chmod } = await import('node:fs/promises');
|
|
132
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (entry.isFile() && entry.name.endsWith('.sh')) {
|
|
135
|
+
await chmod(join(dir, entry.name), 0o755);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function mergeSettingsHook(projectDir) {
|
|
140
|
+
const snippetPath = join(projectDir, 'scripts', 'surfer-gate', 'settings-snippet.json');
|
|
141
|
+
const settingsPath = join(projectDir, '.claude', 'settings.json');
|
|
142
|
+
if (!existsSync(snippetPath))
|
|
143
|
+
return;
|
|
144
|
+
const snippet = JSON.parse(await readFile(snippetPath, 'utf-8'));
|
|
145
|
+
const productionHook = snippet?.production_hook;
|
|
146
|
+
if (!productionHook?.PreToolUse)
|
|
147
|
+
return;
|
|
148
|
+
let settings = {};
|
|
149
|
+
if (existsSync(settingsPath)) {
|
|
150
|
+
try {
|
|
151
|
+
settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Existing settings.json is unreadable — leave it alone.
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
await mkdir(join(projectDir, '.claude'), { recursive: true });
|
|
160
|
+
}
|
|
161
|
+
const existingHooks = (settings.hooks ?? {});
|
|
162
|
+
const existingPreTool = (existingHooks.PreToolUse ?? []);
|
|
163
|
+
const alreadyHasGate = existingPreTool.some((entry) => {
|
|
164
|
+
const hooks = (entry?.hooks ?? []);
|
|
165
|
+
return hooks.some((h) => typeof h?.command === 'string' && h.command.includes('surfer-gate/check.sh'));
|
|
166
|
+
});
|
|
167
|
+
if (alreadyHasGate)
|
|
168
|
+
return;
|
|
169
|
+
settings.hooks = {
|
|
170
|
+
...existingHooks,
|
|
171
|
+
PreToolUse: [...existingPreTool, ...productionHook.PreToolUse],
|
|
172
|
+
};
|
|
173
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
174
|
+
}
|
|
118
175
|
// ── Identity Injection ───────────────────────────────────
|
|
119
176
|
async function injectIdentity(projectDir, config) {
|
|
120
177
|
const claudePath = join(projectDir, 'CLAUDE.md');
|