@everystack/mcp 0.2.2 → 0.3.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/LICENSE +681 -0
- package/README.md +45 -10
- package/dist/adding-database.md +169 -0
- package/dist/admin.md +81 -0
- package/dist/auth.md +115 -0
- package/dist/aws-setup.md +276 -0
- package/dist/cli.md +108 -0
- package/dist/client-api.md +145 -0
- package/dist/core.md +196 -0
- package/dist/deployment.md +146 -0
- package/dist/events.md +87 -0
- package/dist/first-run.md +100 -0
- package/dist/getting-started.md +75 -0
- package/dist/handler-options.md +114 -0
- package/dist/images.md +93 -0
- package/dist/index.cjs +23726 -0
- package/dist/jobs.md +97 -0
- package/dist/logging.md +91 -0
- package/dist/plugins.md +68 -0
- package/dist/project-claude-md.md +102 -0
- package/dist/query-protocol.md +129 -0
- package/dist/schema-patterns.md +167 -0
- package/dist/security-device.md +99 -0
- package/dist/security.md +270 -0
- package/dist/ssr.md +82 -0
- package/dist/storage.md +63 -0
- package/dist/testing.md +118 -0
- package/package.json +26 -14
- package/src/gates/detectors/embedded-data-bundle.ts +58 -0
- package/src/gates/detectors/hand-written-migration.ts +42 -0
- package/src/gates/detectors/secret-in-public-env.ts +41 -0
- package/src/gates/engine.ts +80 -0
- package/src/gates/registry.ts +25 -0
- package/src/gates/telemetry.ts +143 -0
- package/src/gates/types.ts +70 -0
- package/src/governance/cli.ts +193 -0
- package/src/governance/grounding.ts +344 -0
- package/src/index.ts +97 -50
- package/src/prompts/claude-md.ts +90 -0
- package/src/prompts/governance-setup.ts +85 -0
- package/src/prompts/index.ts +4 -0
- package/src/prompts/new-app.ts +4 -1
- package/src/resources/project-claude-md.md +69 -94
- package/src/tools/index.ts +6 -39
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheat: bundling a large data file into the app (the 180MB-JSON failure).
|
|
3
|
+
*
|
|
4
|
+
* Data belongs in PostgreSQL, served through the API — not shipped as a static
|
|
5
|
+
* asset or imported blob. This catches the file *as it lands*: a Write of a data
|
|
6
|
+
* file whose content exceeds the budget. The authoritative gate-to-prod check is
|
|
7
|
+
* `bundle:audit`'s weight dimension; this is the cheap authoring-time nudge.
|
|
8
|
+
*
|
|
9
|
+
* Pure: size is measured from the proposed content, no filesystem read.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { basename, relative } from 'path';
|
|
13
|
+
import type { CheatGate, ToolCallContext } from '../types.js';
|
|
14
|
+
|
|
15
|
+
/** Files over this many bytes of bundled data are almost certainly DB-shaped data. */
|
|
16
|
+
const DATA_BYTE_BUDGET = 512 * 1024; // 0.5 MB
|
|
17
|
+
|
|
18
|
+
const DATA_EXT = /\.(json|csv|tsv|geojson|ndjson|xml)$/i;
|
|
19
|
+
/** Config/lockfiles that are legitimately data-shaped — never flagged. */
|
|
20
|
+
const EXEMPT = /^(package(-lock)?\.json|tsconfig.*\.json|app\.json|app\.config\.json|eas\.json|.*\.config\.json|manifest\.json|tsconfig\.json)$/i;
|
|
21
|
+
|
|
22
|
+
function formatBytes(n: number): string {
|
|
23
|
+
if (n < 1024) return `${n} B`;
|
|
24
|
+
const units = ['KB', 'MB', 'GB'];
|
|
25
|
+
let i = -1;
|
|
26
|
+
do {
|
|
27
|
+
n /= 1024;
|
|
28
|
+
i++;
|
|
29
|
+
} while (n >= 1024 && i < units.length - 1);
|
|
30
|
+
return `${n.toFixed(1)} ${units[i]}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rel(ctx: ToolCallContext, p: string): string {
|
|
34
|
+
try {
|
|
35
|
+
return relative(ctx.cwd, p) || p;
|
|
36
|
+
} catch {
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const embeddedDataBundle: CheatGate = {
|
|
42
|
+
id: 'embedded-data-bundle',
|
|
43
|
+
tier: 'framework',
|
|
44
|
+
severity: 'deny',
|
|
45
|
+
guide:
|
|
46
|
+
'Data belongs in PostgreSQL, served through the API — model it and load it (or render an empty state if it does not exist yet). Never bundle large data into the app.',
|
|
47
|
+
conform: 'define a Model + everystack db:generate + an API hook (or the ingest pipeline)',
|
|
48
|
+
verify: 'everystack bundle:audit stays under the weight budget',
|
|
49
|
+
detect(ctx: ToolCallContext): string | null {
|
|
50
|
+
if (ctx.tool !== 'Write') return null; // Write lands the whole file; Edit content is partial
|
|
51
|
+
const p = ctx.filePath;
|
|
52
|
+
if (!p || !DATA_EXT.test(p)) return null;
|
|
53
|
+
if (EXEMPT.test(basename(p))) return null;
|
|
54
|
+
const bytes = Buffer.byteLength(ctx.content ?? '', 'utf8');
|
|
55
|
+
if (bytes < DATA_BYTE_BUDGET) return null;
|
|
56
|
+
return `${rel(ctx, p)} is ${formatBytes(bytes)} of data bundled into the app`;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheat: hand-writing a migration or editing the generated schema.
|
|
3
|
+
*
|
|
4
|
+
* In v3 the schema and its migrations are *generated* from the Models
|
|
5
|
+
* (`db:generate`). A hand-written `.sql` migration or a hand-edit to the generated
|
|
6
|
+
* `db/schema.ts` drifts the database from the Models — the migration the CLI would
|
|
7
|
+
* write next is no longer a clean no-op. Pure path check; no IO.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { relative } from 'path';
|
|
11
|
+
import type { CheatGate, ToolCallContext } from '../types.js';
|
|
12
|
+
|
|
13
|
+
/** A `.sql` file under a migrations/drizzle directory — these are generated, never authored. */
|
|
14
|
+
const MIGRATION_SQL = /(?:^|\/)(?:drizzle|migrations)\/[^/]+\.sql$/i;
|
|
15
|
+
/** The generated Drizzle schema mirror. */
|
|
16
|
+
const GENERATED_SCHEMA = /(?:^|\/)(?:db|drizzle)\/schema\.ts$/;
|
|
17
|
+
|
|
18
|
+
function rel(ctx: ToolCallContext, p: string): string {
|
|
19
|
+
try {
|
|
20
|
+
return relative(ctx.cwd, p) || p;
|
|
21
|
+
} catch {
|
|
22
|
+
return p;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const handWrittenMigration: CheatGate = {
|
|
27
|
+
id: 'hand-written-migration',
|
|
28
|
+
tier: 'framework',
|
|
29
|
+
severity: 'deny',
|
|
30
|
+
guide:
|
|
31
|
+
'Schema and migrations are generated from your Models — edit the Model and run db:generate, never hand-write SQL migrations or edit the generated schema.',
|
|
32
|
+
conform: 'everystack db:generate',
|
|
33
|
+
verify: 'everystack db:generate produces no diff (a clean no-op)',
|
|
34
|
+
detect(ctx: ToolCallContext): string | null {
|
|
35
|
+
if (ctx.tool !== 'Write' && ctx.tool !== 'Edit') return null;
|
|
36
|
+
const p = ctx.filePath;
|
|
37
|
+
if (!p) return null;
|
|
38
|
+
if (MIGRATION_SQL.test(p)) return `${rel(ctx, p)} is a SQL migration being written by hand`;
|
|
39
|
+
if (GENERATED_SCHEMA.test(p)) return `${rel(ctx, p)} is generated from your Models`;
|
|
40
|
+
return null;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheat: a secret value behind an EXPO_PUBLIC_* name.
|
|
3
|
+
*
|
|
4
|
+
* Metro compiles EXPO_PUBLIC_* env vars into the client bundle — public, forever.
|
|
5
|
+
* A DB URL / API key / token behind one of those names is leaked the moment it
|
|
6
|
+
* ships. Reuses the canonical secret-shape patterns from `@everystack/cli/audit`
|
|
7
|
+
* (no duplicated, drift-prone pattern list), loaded lazily behind a cheap
|
|
8
|
+
* pre-filter so the common tool call never pulls the audit graph.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CheatGate, ToolCallContext } from '../types.js';
|
|
12
|
+
|
|
13
|
+
const PUBLIC_KEY = /\b(EXPO_PUBLIC_[A-Z0-9_]+)\b/;
|
|
14
|
+
|
|
15
|
+
export const secretInPublicEnv: CheatGate = {
|
|
16
|
+
id: 'secret-in-public-env',
|
|
17
|
+
tier: 'framework',
|
|
18
|
+
severity: 'deny',
|
|
19
|
+
guide:
|
|
20
|
+
'EXPO_PUBLIC_* is compiled into the client bundle forever — never put a secret behind it. Keep secrets server-side and reference them only on the server.',
|
|
21
|
+
conform: 'everystack secrets set (SSM) / an SST secret, referenced server-side only',
|
|
22
|
+
verify: 'everystack bundle:audit (leak scan) finds no leaked secret',
|
|
23
|
+
async detect(ctx: ToolCallContext): Promise<string | null> {
|
|
24
|
+
if (ctx.tool !== 'Write' && ctx.tool !== 'Edit') return null;
|
|
25
|
+
const text = ctx.content;
|
|
26
|
+
// Cheap sync pre-filter: only env-ish writes pay for the canonical scanner.
|
|
27
|
+
if (!text || !text.includes('EXPO_PUBLIC_')) return null;
|
|
28
|
+
|
|
29
|
+
const { SECRET_PATTERNS, isPublicEnvKey } = await import('@everystack/cli/audit');
|
|
30
|
+
for (const line of text.split('\n')) {
|
|
31
|
+
const m = line.match(PUBLIC_KEY);
|
|
32
|
+
if (!m || !isPublicEnvKey(m[1])) continue;
|
|
33
|
+
for (const { name, re } of SECRET_PATTERNS) {
|
|
34
|
+
if (re.test(line)) {
|
|
35
|
+
return `${m[1]} carries a ${name} — a secret value compiled into the client bundle`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine — run a set of cheat gates against a tool-call context.
|
|
3
|
+
*
|
|
4
|
+
* Pure evaluation (no IO): `evaluateGates` returns findings; `decide` collapses
|
|
5
|
+
* them to an allow/deny decision (deny wins if any `deny`-tier gate hit). The
|
|
6
|
+
* caller (governance/cli.ts) records telemetry and renders the hook payload —
|
|
7
|
+
* keeping detection pure and side effects at the boundary.
|
|
8
|
+
*
|
|
9
|
+
* Fail-OPEN: a detector that throws is skipped, never propagated — a governance
|
|
10
|
+
* bug must not brick tool use.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CheatGate, GateFinding, ToolCallContext } from './types.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run every gate's detector against the context; collect findings. Detectors that
|
|
17
|
+
* throw are skipped (fail-open). `detect` may be sync or async — async lets a gate
|
|
18
|
+
* lazy-load a heavier scanner only after a cheap sync pre-filter matches, so the
|
|
19
|
+
* common tool call pays nothing.
|
|
20
|
+
*/
|
|
21
|
+
export async function evaluateGates(ctx: ToolCallContext, gates: CheatGate[]): Promise<GateFinding[]> {
|
|
22
|
+
const findings: GateFinding[] = [];
|
|
23
|
+
for (const g of gates) {
|
|
24
|
+
let detail: string | null = null;
|
|
25
|
+
try {
|
|
26
|
+
detail = await g.detect(ctx);
|
|
27
|
+
} catch {
|
|
28
|
+
detail = null; // fail open on a buggy detector
|
|
29
|
+
}
|
|
30
|
+
if (detail == null) continue;
|
|
31
|
+
findings.push({
|
|
32
|
+
id: g.id,
|
|
33
|
+
tier: g.tier,
|
|
34
|
+
severity: g.severity,
|
|
35
|
+
guide: g.guide,
|
|
36
|
+
conform: g.conform,
|
|
37
|
+
verify: g.verify,
|
|
38
|
+
detail,
|
|
39
|
+
file: ctx.filePath,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return findings;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GateDecision {
|
|
46
|
+
decision: 'allow' | 'deny';
|
|
47
|
+
findings: GateFinding[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Collapse findings to a decision: deny if any deny-tier finding, else allow. */
|
|
51
|
+
export function decide(findings: GateFinding[]): GateDecision {
|
|
52
|
+
const decision = findings.some((f) => f.severity === 'deny') ? 'deny' : 'allow';
|
|
53
|
+
return { decision, findings };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The PreToolUse deny/warn reason: each finding as Guide → Conform → Verify. */
|
|
57
|
+
export function findingsReason(findings: GateFinding[]): string {
|
|
58
|
+
const lines: string[] = [];
|
|
59
|
+
const denies = findings.filter((f) => f.severity === 'deny');
|
|
60
|
+
const warns = findings.filter((f) => f.severity === 'warn');
|
|
61
|
+
if (denies.length) {
|
|
62
|
+
lines.push('OFF-SCRIPT — this cheats the everystack architecture:');
|
|
63
|
+
for (const f of denies) lines.push(...renderFinding(f));
|
|
64
|
+
}
|
|
65
|
+
if (warns.length) {
|
|
66
|
+
if (denies.length) lines.push('');
|
|
67
|
+
lines.push('Heads up (advisory):');
|
|
68
|
+
for (const f of warns) lines.push(...renderFinding(f));
|
|
69
|
+
}
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderFinding(f: GateFinding): string[] {
|
|
74
|
+
return [
|
|
75
|
+
` • [${f.id}] ${f.detail}`,
|
|
76
|
+
` do this: ${f.guide}`,
|
|
77
|
+
` with: ${f.conform}`,
|
|
78
|
+
` verify: ${f.verify}`,
|
|
79
|
+
];
|
|
80
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cheat-gate registry.
|
|
3
|
+
*
|
|
4
|
+
* Framework tier: on by default for any everystack project — the gates whose
|
|
5
|
+
* Detect is local and whose Verify already ships (db:generate, bundle:audit).
|
|
6
|
+
* Project tier (declared per project via a recipe manifest) lands in a later
|
|
7
|
+
* brick; `gatesFor` is the seam where that composition will happen.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CheatGate } from './types.js';
|
|
11
|
+
import { handWrittenMigration } from './detectors/hand-written-migration.js';
|
|
12
|
+
import { embeddedDataBundle } from './detectors/embedded-data-bundle.js';
|
|
13
|
+
import { secretInPublicEnv } from './detectors/secret-in-public-env.js';
|
|
14
|
+
|
|
15
|
+
/** Framework-tier gates, on by default. */
|
|
16
|
+
export const FRAMEWORK_GATES: CheatGate[] = [
|
|
17
|
+
handWrittenMigration,
|
|
18
|
+
embeddedDataBundle,
|
|
19
|
+
secretInPublicEnv,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** The gates that apply to a working directory. Project-tier composition is added later. */
|
|
23
|
+
export function gatesFor(_cwd: string): CheatGate[] {
|
|
24
|
+
return FRAMEWORK_GATES;
|
|
25
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry — the gate is also the sensor.
|
|
3
|
+
*
|
|
4
|
+
* Every cheat finding is appended to a per-session JSONL under
|
|
5
|
+
* ~/.everystack/governance/<session>.jsonl. Pointed at real testers, this file is
|
|
6
|
+
* the off-rails dataset: which cheats the agent attempts, how often, what gets
|
|
7
|
+
* denied vs warned. `report` summarises it (the empirical loop that ranks which
|
|
8
|
+
* gates and generators matter next).
|
|
9
|
+
*
|
|
10
|
+
* Privacy: record rule + path + severity only — never file contents.
|
|
11
|
+
* Fail-OPEN: a telemetry write must never throw into the hook path.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, appendFileSync, readdirSync, readFileSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
import type { GateFinding } from './types.js';
|
|
18
|
+
|
|
19
|
+
function home(): string {
|
|
20
|
+
return process.env.HOME || homedir();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The telemetry directory: ~/.everystack/governance. */
|
|
24
|
+
export function telemetryDir(): string {
|
|
25
|
+
return join(home(), '.everystack', 'governance');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeId(sessionId: string): string {
|
|
29
|
+
return (sessionId || 'nosession').replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TelemetryEvent {
|
|
33
|
+
ts: string;
|
|
34
|
+
session: string;
|
|
35
|
+
cwd: string;
|
|
36
|
+
rule: string;
|
|
37
|
+
tier: string;
|
|
38
|
+
severity: string;
|
|
39
|
+
tool: string;
|
|
40
|
+
file?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Append one telemetry line per finding. Decision (deny vs warn) is implicit in
|
|
45
|
+
* `severity`. Never throws.
|
|
46
|
+
*/
|
|
47
|
+
export function recordFindings(
|
|
48
|
+
sessionId: string,
|
|
49
|
+
ctx: { cwd: string; tool: string },
|
|
50
|
+
findings: GateFinding[]
|
|
51
|
+
): void {
|
|
52
|
+
if (findings.length === 0) return;
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(telemetryDir(), { recursive: true });
|
|
55
|
+
const path = join(telemetryDir(), `${safeId(sessionId)}.jsonl`);
|
|
56
|
+
const ts = new Date().toISOString();
|
|
57
|
+
const lines = findings
|
|
58
|
+
.map((f) => {
|
|
59
|
+
const ev: TelemetryEvent = {
|
|
60
|
+
ts,
|
|
61
|
+
session: safeId(sessionId),
|
|
62
|
+
cwd: ctx.cwd,
|
|
63
|
+
rule: f.id,
|
|
64
|
+
tier: f.tier,
|
|
65
|
+
severity: f.severity,
|
|
66
|
+
tool: ctx.tool,
|
|
67
|
+
file: f.file,
|
|
68
|
+
};
|
|
69
|
+
return JSON.stringify(ev);
|
|
70
|
+
})
|
|
71
|
+
.join('\n');
|
|
72
|
+
appendFileSync(path, lines + '\n');
|
|
73
|
+
} catch {
|
|
74
|
+
/* fail open: telemetry never bricks the hook */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read all events for one session, or every session when sessionId is omitted. */
|
|
79
|
+
export function readEvents(sessionId?: string): TelemetryEvent[] {
|
|
80
|
+
const events: TelemetryEvent[] = [];
|
|
81
|
+
let files: string[];
|
|
82
|
+
try {
|
|
83
|
+
files = readdirSync(telemetryDir()).filter((f) => f.endsWith('.jsonl'));
|
|
84
|
+
} catch {
|
|
85
|
+
return events;
|
|
86
|
+
}
|
|
87
|
+
const wanted = sessionId ? `${safeId(sessionId)}.jsonl` : null;
|
|
88
|
+
for (const f of files) {
|
|
89
|
+
if (wanted && f !== wanted) continue;
|
|
90
|
+
try {
|
|
91
|
+
const text = readFileSync(join(telemetryDir(), f), 'utf-8');
|
|
92
|
+
for (const line of text.split('\n')) {
|
|
93
|
+
const t = line.trim();
|
|
94
|
+
if (!t) continue;
|
|
95
|
+
try {
|
|
96
|
+
events.push(JSON.parse(t) as TelemetryEvent);
|
|
97
|
+
} catch {
|
|
98
|
+
/* skip a malformed line, keep the rest */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
/* skip an unreadable file */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return events;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface TelemetrySummary {
|
|
109
|
+
total: number;
|
|
110
|
+
denies: number;
|
|
111
|
+
warns: number;
|
|
112
|
+
byRule: Record<string, number>;
|
|
113
|
+
sessions: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Tally events into a summary (the `report` payload). */
|
|
117
|
+
export function summarize(sessionId?: string): TelemetrySummary {
|
|
118
|
+
const events = readEvents(sessionId);
|
|
119
|
+
const byRule: Record<string, number> = {};
|
|
120
|
+
const sessions = new Set<string>();
|
|
121
|
+
let denies = 0;
|
|
122
|
+
let warns = 0;
|
|
123
|
+
for (const e of events) {
|
|
124
|
+
byRule[e.rule] = (byRule[e.rule] ?? 0) + 1;
|
|
125
|
+
sessions.add(e.session);
|
|
126
|
+
if (e.severity === 'deny') denies++;
|
|
127
|
+
else if (e.severity === 'warn') warns++;
|
|
128
|
+
}
|
|
129
|
+
return { total: events.length, denies, warns, byRule, sessions: sessions.size };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Human-readable rendering of the summary (the `report` stdout). */
|
|
133
|
+
export function renderSummary(s: TelemetrySummary): string {
|
|
134
|
+
const lines: string[] = [];
|
|
135
|
+
lines.push(`governance telemetry — ${s.total} findings across ${s.sessions} session(s)`);
|
|
136
|
+
lines.push(` ${s.denies} deny · ${s.warns} warn`);
|
|
137
|
+
const ranked = Object.entries(s.byRule).sort((a, b) => b[1] - a[1]);
|
|
138
|
+
if (ranked.length) {
|
|
139
|
+
lines.push(' by rule (most-tripped first):');
|
|
140
|
+
for (const [rule, n] of ranked) lines.push(` ${n.toString().padStart(4)} ${rule}`);
|
|
141
|
+
}
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheat-gate types — the atomic unit of governance.
|
|
3
|
+
*
|
|
4
|
+
* A cheat gate catches the agent *cheating the architecture*: taking the
|
|
5
|
+
* training-consensus shortcut (bundle the JSON, hand-write the migration, leak a
|
|
6
|
+
* secret) that the maintainer would veto on sight. Anatomy, fixed:
|
|
7
|
+
*
|
|
8
|
+
* Detect → Block → Guide → Conform → Verify
|
|
9
|
+
*
|
|
10
|
+
* Governing rule (see docs/plans/everystack-cheat-gates.md): no gate ships without
|
|
11
|
+
* Guide + Conform + Verify. If you can't name what to do instead, the tool that
|
|
12
|
+
* does it, and the check that proves it, the rule is grounding — not a gate.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type GateSeverity = 'deny' | 'warn';
|
|
16
|
+
export type GateTier = 'framework' | 'project';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What a detector inspects at the tool-call boundary, normalised from hook JSON
|
|
20
|
+
* (PreToolUse/PostToolUse) or synthesised for a tree scan. Pure data.
|
|
21
|
+
*/
|
|
22
|
+
export interface ToolCallContext {
|
|
23
|
+
/** The tool about to run / that ran: 'Write' | 'Edit' | 'Bash' | 'Read' | … */
|
|
24
|
+
tool: string;
|
|
25
|
+
/** Target path for Write/Edit/Read. */
|
|
26
|
+
filePath?: string;
|
|
27
|
+
/** Proposed file content (Write) or the new text (Edit) — what is about to land. */
|
|
28
|
+
content?: string;
|
|
29
|
+
/** Command line for Bash. */
|
|
30
|
+
command?: string;
|
|
31
|
+
/** Working directory of the session. */
|
|
32
|
+
cwd: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A single cheat detected — carries its own remediation and proof. */
|
|
36
|
+
export interface GateFinding {
|
|
37
|
+
id: string;
|
|
38
|
+
tier: GateTier;
|
|
39
|
+
severity: GateSeverity;
|
|
40
|
+
/** One imperative sentence: what to do instead. */
|
|
41
|
+
guide: string;
|
|
42
|
+
/** The sanctioned tool/command/generator that does it right. */
|
|
43
|
+
conform: string;
|
|
44
|
+
/** The runnable check that proves the result is clean. */
|
|
45
|
+
verify: string;
|
|
46
|
+
/** Specifics of this hit (e.g. "assets/data.json is 184.0 MB"). */
|
|
47
|
+
detail: string;
|
|
48
|
+
/** The file the cheat lives in, when applicable. */
|
|
49
|
+
file?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A cheat gate. `detect` is pure + synchronous: it returns a detail string when
|
|
54
|
+
* the cheat is present in the given context, or null when the context is clean.
|
|
55
|
+
* The Guide/Conform/Verify fields are static metadata copied onto every finding.
|
|
56
|
+
*/
|
|
57
|
+
export interface CheatGate {
|
|
58
|
+
id: string;
|
|
59
|
+
tier: GateTier;
|
|
60
|
+
severity: GateSeverity;
|
|
61
|
+
guide: string;
|
|
62
|
+
conform: string;
|
|
63
|
+
verify: string;
|
|
64
|
+
/**
|
|
65
|
+
* Returns a detail string when the cheat is present, else null. May be sync or
|
|
66
|
+
* async — keep the common path sync and cheap; use async only to lazy-load a
|
|
67
|
+
* heavier scanner *after* a cheap pre-filter matches.
|
|
68
|
+
*/
|
|
69
|
+
detect(ctx: ToolCallContext): string | null | Promise<string | null>;
|
|
70
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* governance/cli — the hook-shim entry for `everystack-mcp <subcommand>`.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code hooks pipe their JSON event on stdin to one of these subcommands;
|
|
5
|
+
* the hook config itself carries no logic (the local-agent grounding-gate shape).
|
|
6
|
+
* The handlers are exported so the same logic is unit-testable and so the operator
|
|
7
|
+
* CLI can self-gate on it later — no drift between what the hook blocks and what
|
|
8
|
+
* the gate-to-prod refuses.
|
|
9
|
+
*
|
|
10
|
+
* This module stays LIGHT to import: it pulls only grounding + the pure engine +
|
|
11
|
+
* telemetry. The one heavy dependency (the canonical secret scanner) is lazy-loaded
|
|
12
|
+
* inside its detector behind a cheap pre-filter, so the common tool call is fast.
|
|
13
|
+
*
|
|
14
|
+
* Fail-OPEN on any internal error or unknown input — a governance bug must never
|
|
15
|
+
* brick tool use globally. Only the explicit "ungrounded" / "off-script deny"
|
|
16
|
+
* conditions fail closed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { context, gate, mark, denyReason } from './grounding.js';
|
|
20
|
+
import { evaluateGates, decide, findingsReason } from '../gates/engine.js';
|
|
21
|
+
import { gatesFor } from '../gates/registry.js';
|
|
22
|
+
import { recordFindings, summarize, renderSummary } from '../gates/telemetry.js';
|
|
23
|
+
import type { ToolCallContext } from '../gates/types.js';
|
|
24
|
+
|
|
25
|
+
export const GOVERNANCE_COMMANDS = [
|
|
26
|
+
'context', // SessionStart: inject the grounding contract
|
|
27
|
+
'gate', // PreToolUse: allow/deny (grounding + cheat gates)
|
|
28
|
+
'mark', // PostToolUse(Read): record a required-file read
|
|
29
|
+
'validate', // PostToolUse(Write|Edit): surface any cheat the write landed
|
|
30
|
+
'report', // summarize the per-session telemetry (the sensor)
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export type GovernanceCommand = (typeof GOVERNANCE_COMMANDS)[number];
|
|
34
|
+
|
|
35
|
+
interface HookInput {
|
|
36
|
+
session_id?: string;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
source?: string;
|
|
39
|
+
tool_name?: string;
|
|
40
|
+
tool_input?: {
|
|
41
|
+
file_path?: string;
|
|
42
|
+
content?: string;
|
|
43
|
+
new_string?: string;
|
|
44
|
+
old_string?: string;
|
|
45
|
+
command?: string;
|
|
46
|
+
offset?: number;
|
|
47
|
+
limit?: number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Deny payload for PreToolUse. */
|
|
52
|
+
function denyPayload(reason: string): unknown {
|
|
53
|
+
return {
|
|
54
|
+
hookSpecificOutput: {
|
|
55
|
+
hookEventName: 'PreToolUse',
|
|
56
|
+
permissionDecision: 'deny',
|
|
57
|
+
permissionDecisionReason: reason,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Non-blocking advisory payload for PostToolUse. */
|
|
63
|
+
function contextPayload(text: string): unknown {
|
|
64
|
+
return {
|
|
65
|
+
hookSpecificOutput: {
|
|
66
|
+
hookEventName: 'PostToolUse',
|
|
67
|
+
additionalContext: text,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toToolContext(input: HookInput): ToolCallContext {
|
|
73
|
+
const ti = input.tool_input ?? {};
|
|
74
|
+
return {
|
|
75
|
+
tool: input.tool_name ?? '',
|
|
76
|
+
filePath: ti.file_path,
|
|
77
|
+
// Write carries `content`; Edit carries `new_string` (what is about to land).
|
|
78
|
+
content: ti.content ?? ti.new_string,
|
|
79
|
+
command: ti.command,
|
|
80
|
+
cwd: input.cwd ?? process.cwd(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Handlers (exported for tests; pure-ish — telemetry is the only side effect)
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** SessionStart: the grounding contract text. */
|
|
89
|
+
export function handleContext(input: HookInput): string {
|
|
90
|
+
const sid = input.session_id ?? 'nosession';
|
|
91
|
+
const cwd = input.cwd ?? process.cwd();
|
|
92
|
+
return context(sid, cwd, input.source ?? 'startup');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* PreToolUse: grounding first (the contract must be read), then cheat gates. Returns
|
|
97
|
+
* a deny payload object to emit, or null to allow. Telemetry is recorded for any
|
|
98
|
+
* cheat findings at this boundary (the authoritative "attempt" event).
|
|
99
|
+
*/
|
|
100
|
+
export async function handleGate(input: HookInput): Promise<unknown | null> {
|
|
101
|
+
const sid = input.session_id ?? 'nosession';
|
|
102
|
+
const cwd = input.cwd ?? process.cwd();
|
|
103
|
+
const tool = input.tool_name ?? '';
|
|
104
|
+
|
|
105
|
+
// 1) Grounding: until the contract is read, every non-Read tool is denied.
|
|
106
|
+
const g = gate(sid, cwd, tool);
|
|
107
|
+
if (g.decision === 'deny') return denyPayload(denyReason(g.unread));
|
|
108
|
+
|
|
109
|
+
// 2) Cheat gates.
|
|
110
|
+
const ctx = toToolContext(input);
|
|
111
|
+
const findings = await evaluateGates(ctx, gatesFor(cwd));
|
|
112
|
+
if (findings.length) recordFindings(sid, { cwd, tool }, findings);
|
|
113
|
+
if (decide(findings).decision === 'deny') return denyPayload(findingsReason(findings));
|
|
114
|
+
return null; // allow (any warn-tier findings are recorded, not blocked)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* PostToolUse(Write|Edit): re-scan after the write and surface anything (a backstop
|
|
119
|
+
* for what a pre-write check couldn't see). Non-blocking. Does NOT re-record — the
|
|
120
|
+
* attempt was already counted at the gate — it only surfaces guidance.
|
|
121
|
+
*/
|
|
122
|
+
export async function handleValidate(input: HookInput): Promise<unknown | null> {
|
|
123
|
+
const cwd = input.cwd ?? process.cwd();
|
|
124
|
+
const ctx = toToolContext(input);
|
|
125
|
+
const findings = await evaluateGates(ctx, gatesFor(cwd));
|
|
126
|
+
if (!findings.length) return null;
|
|
127
|
+
return contextPayload(findingsReason(findings));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** PostToolUse(Read): mark a required-file read toward grounding. */
|
|
131
|
+
export function handleMark(input: HookInput): void {
|
|
132
|
+
const sid = input.session_id ?? 'nosession';
|
|
133
|
+
const cwd = input.cwd ?? process.cwd();
|
|
134
|
+
const ti = input.tool_input ?? {};
|
|
135
|
+
if (!ti.file_path) return;
|
|
136
|
+
const partial = ti.offset !== undefined || ti.limit !== undefined;
|
|
137
|
+
mark(sid, cwd, ti.file_path, partial);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// stdin / dispatch
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function readStdin(): Promise<string> {
|
|
145
|
+
return new Promise((res) => {
|
|
146
|
+
let data = '';
|
|
147
|
+
if (process.stdin.isTTY) {
|
|
148
|
+
res('');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
process.stdin.setEncoding('utf-8');
|
|
152
|
+
process.stdin.on('data', (c) => (data += c));
|
|
153
|
+
process.stdin.on('end', () => res(data));
|
|
154
|
+
process.stdin.on('error', () => res(data));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Dispatch a governance subcommand from hook JSON on stdin. `report` reads
|
|
160
|
+
* telemetry instead of stdin and prints a summary. Always fails open.
|
|
161
|
+
*/
|
|
162
|
+
export async function runGovernanceCli(argv: string[]): Promise<void> {
|
|
163
|
+
const mode = argv[0] as GovernanceCommand | undefined;
|
|
164
|
+
if (!mode || !(GOVERNANCE_COMMANDS as readonly string[]).includes(mode)) return;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
if (mode === 'report') {
|
|
168
|
+
process.stdout.write(renderSummary(summarize(argv[1])) + '\n');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let input: HookInput = {};
|
|
173
|
+
try {
|
|
174
|
+
input = JSON.parse((await readStdin()) || '{}');
|
|
175
|
+
} catch {
|
|
176
|
+
return; // fail open on malformed input
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (mode === 'context') {
|
|
180
|
+
process.stdout.write(handleContext(input) + '\n');
|
|
181
|
+
} else if (mode === 'gate') {
|
|
182
|
+
const payload = await handleGate(input);
|
|
183
|
+
if (payload) process.stdout.write(JSON.stringify(payload));
|
|
184
|
+
} else if (mode === 'mark') {
|
|
185
|
+
handleMark(input);
|
|
186
|
+
} else if (mode === 'validate') {
|
|
187
|
+
const payload = await handleValidate(input);
|
|
188
|
+
if (payload) process.stdout.write(JSON.stringify(payload));
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
/* fail open: stay silent, exit 0 */
|
|
192
|
+
}
|
|
193
|
+
}
|