delimit-cli 4.2.0 → 4.3.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/README.md CHANGED
@@ -16,8 +16,7 @@ The universal command for the Delimit Swarm. When you say **"Think and Build"**,
16
16
  Works across any configuration — from a single model on a budget to an enterprise swarm of 4+ models.
17
17
 
18
18
  [![npm](https://img.shields.io/npm/v/delimit-cli)](https://www.npmjs.com/package/delimit-cli)
19
- [![Tests](https://img.shields.io/badge/tests-123%20passing-brightgreen)](https://github.com/delimit-ai/delimit-mcp-server)
20
- [![MCP Tools](https://img.shields.io/badge/MCP%20tools-186-blue)](https://delimit.ai)
19
+ [![Tests](https://img.shields.io/badge/tests-134%20passing-brightgreen)](https://github.com/delimit-ai/delimit-mcp-server)
21
20
  [![GitHub Action](https://img.shields.io/badge/GitHub%20Action-v1.6.0-blue)](https://github.com/marketplace/actions/delimit-api-governance)
22
21
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
23
22
  [![Glama](https://glama.ai/mcp/servers/delimit-ai/delimit/badge)](https://glama.ai/mcp/servers/delimit-ai/delimit)
@@ -70,6 +69,34 @@ npx delimit-cli init # Sets up governance + drift baseline
70
69
 
71
70
  ---
72
71
 
72
+ ## What's New in v4.3
73
+
74
+ *Gate every AI-assisted invocation. Ship the receipts.*
75
+
76
+ - **`delimit wrap`** — pipe `claude -p`, `cursor`, `aider`, `codex`, or any AI-assisted CLI through a signed governance gate. Snapshots the git diff before/after, runs lint + tests, HMAC-signs an `att_*` attestation, emits a public replay URL. Advisory by default; `--enforce` blocks CI on policy violations; `--max-time <s>` is a kill switch that tags the attestation as a `liability_incident` and prints a cross-model handoff command.
77
+ - **`delimit trust-page`** — renders a directory of attestations into a static HTML trust page + JSON Feed 1.1 feed. Single file, no framework, offline-renderable. Deploy anywhere.
78
+ - **`delimit ai-sbom`** — aggregates attestations into a CycloneDX 1.6 bill-of-materials with AI-specific fields (detected models per vendor, tool-call surface, policy gate counts). Pipe straight into procurement.
79
+ - **Cross-model by construction** — `wrap` is agnostic to the producer. Same attestation schema whether the pipe upstream is Claude Code, Cursor, Aider, Codex, or Gemini CLI. Switch producers without losing the audit chain.
80
+
81
+ ```bash
82
+ # Gate any AI-assisted CLI
83
+ delimit wrap -- claude -p "add tests for payments"
84
+ # → att_7d556843c84fb881 signed, replay: https://delimit.ai/att/att_7d556843c84fb881
85
+
86
+ # Kill switch + handoff after 60s wall-clock
87
+ delimit wrap --max-time 60 -- cursor edit "refactor auth middleware"
88
+ # → if killed: kind=liability_incident
89
+ # → suggested: delimit wrap -- claude -p "refactor auth middleware"
90
+
91
+ # Render accumulated attestations as a public trust page
92
+ delimit trust-page -o ./trust
93
+ # → ./trust/index.html (+ feed.json)
94
+
95
+ # Build a CycloneDX-AI bill of materials
96
+ delimit ai-sbom -o ./ai-sbom.json
97
+ # → components: 4 models detected, 187 gates run
98
+ ```
99
+
73
100
  ## What's New in v4.20
74
101
 
75
102
  *The highest state of AI governance.*
@@ -121,7 +148,7 @@ delimit deliberate "Should we build rate limiting in-house or use a managed serv
121
148
  - **Cross-Model Audit** -- 3 lenses (security, correctness, governance) with deterministic synthesis
122
149
  - **4-model deliberation** -- Claude + Grok + Gemini + Codex debate until consensus
123
150
  - **Universal Swarm Triggers** -- "Think and Build", "Keep building", "Ask Delimit"
124
- - **187 MCP tools** -- governance, context, shipping, observability, orchestration, and swarm
151
+ - **Full governance toolkit** -- lint, diff, policy, evidence, drift, attestation, and swarm orchestration exposed as MCP tools and CLI subcommands
125
152
 
126
153
  ---
127
154
 
@@ -213,6 +240,10 @@ npx delimit-cli models --status # Show current model config
213
240
  npx delimit-cli status # Compact dashboard of your Delimit setup
214
241
  npx delimit-cli doctor # Check setup health
215
242
  npx delimit-cli uninstall --dry-run # Preview removal
243
+ npx delimit-cli wrap -- claude -p "..." # Gate any AI-assisted CLI + signed attestation (v4.3)
244
+ npx delimit-cli wrap --max-time 60 -- codex "..."# With kill switch + handoff on timeout
245
+ npx delimit-cli trust-page -o ./trust # Render attestations into a static trust page
246
+ npx delimit-cli ai-sbom -o ./ai-sbom.json # Build a CycloneDX-AI bill of materials
216
247
  ```
217
248
 
218
249
  ### What the MCP toolkit adds
@@ -330,6 +361,37 @@ The free tier includes API governance, persistent memory, zero-spec extraction,
330
361
 
331
362
  ---
332
363
 
364
+ ## Telemetry & cloud sync
365
+
366
+ **Short version: none by default.** Nothing leaves your machine unless you explicitly configure it.
367
+
368
+ **What's always local (source of truth):**
369
+ - `~/.delimit/events/events-YYYY-MM-DD.jsonl` — per-tool-call events (tool name, timestamp, status, model id, session id, trace id). No source code, no prompts, no responses.
370
+ - `~/.delimit/ledger/` — your ledger items, work orders, deliberation transcripts.
371
+ - `~/.delimit/attestations/` — `delimit wrap` output bundles.
372
+
373
+ **What's OPT-IN (requires you to provide your own Supabase project credentials):**
374
+ - `gateway/ai/supabase_sync.py` mirrors the local event + ledger + work-order + deliberation rows into a Supabase project *you own* so you can view them in `app.delimit.ai`. **It only activates if you set `SUPABASE_URL` + `SUPABASE_SERVICE_ROLE_KEY` environment variables OR provide `~/.delimit/secrets/supabase.json` with those credentials.** No URL or key is hardcoded in the published package (verify with `grep -r aqbdqxnhzqzswdxifksc $(npm root -g)/delimit-cli/` — zero hits).
375
+ - Data scope when enabled: metadata only (tool names, timestamps, IDs, statuses, venture tags). Never source code, prompts, or model responses.
376
+
377
+ **Kill switch:**
378
+ Set `DELIMIT_DISABLE_CLOUD_SYNC=1` in your environment to force all sync operations to no-op even if credentials are present. Local files continue to work normally.
379
+
380
+ ```bash
381
+ # Disable cloud sync for a single invocation
382
+ DELIMIT_DISABLE_CLOUD_SYNC=1 delimit lint api/openapi.yaml
383
+
384
+ # Disable for the shell session
385
+ export DELIMIT_DISABLE_CLOUD_SYNC=1
386
+ ```
387
+
388
+ **Webhook notifications:**
389
+ `gateway/ai/notify.py` emits governance events to a webhook endpoint *only if* you configure `DELIMIT_WEBHOOK_URL` explicitly. Unset by default.
390
+
391
+ If you spot another code path that could phone home without disclosure, file an issue. This section is maintained as ship-truth, not aspirational.
392
+
393
+ ---
394
+
333
395
  ## Links
334
396
 
335
397
  - [delimit.ai](https://delimit.ai) -- homepage
@@ -6113,6 +6113,155 @@ program
6113
6113
  console.log(require('../package.json').version);
6114
6114
  });
6115
6115
 
6116
+ // LED-1048: delimit wrap — gate any AI-assisted CLI with signed attestation + replay
6117
+ // Surface 1 CLI-pipe extension. Cross-model-agnostic (claude -p, cursor, aider, codex, ...).
6118
+ // Advisory by default; opt-in --enforce to block on policy violations.
6119
+ program
6120
+ .command('wrap')
6121
+ .description('Gate an AI-assisted CLI invocation with signed attestation (advisory-first)')
6122
+ .argument('<cmd...>', 'The command to wrap (e.g. `claude -p "add tests"`)')
6123
+ .option('--enforce', 'Block exit on policy violation (default: advisory)', false)
6124
+ .option('--deliberate', 'Also run multi-model deliberation (advisory)', false)
6125
+ .option('--no-attest', 'Skip attestation emission (dry run)')
6126
+ .option('--max-time <seconds>', 'Kill switch: SIGKILL the wrapped command after N seconds (liability_incident attestation + handoff)', parseInt)
6127
+ .option('--json', 'Output result as JSON', false)
6128
+ .action(async (cmdParts, options) => {
6129
+ const { runWrap, replayUrl } = require('../lib/wrap-engine');
6130
+ try {
6131
+ const result = await runWrap(cmdParts, {
6132
+ enforce: !!options.enforce,
6133
+ deliberate: !!options.deliberate,
6134
+ attest: options.attest !== false,
6135
+ maxTimeSeconds: options.maxTime || 0,
6136
+ cwd: process.cwd(),
6137
+ });
6138
+
6139
+ if (result.error === 'quota_exceeded') {
6140
+ console.error(chalk.red(`\n [wrap] ${result.message}\n`));
6141
+ process.exit(1);
6142
+ return;
6143
+ }
6144
+
6145
+ if (options.json) {
6146
+ console.log(JSON.stringify(result, null, 2));
6147
+ process.exit(result.exit);
6148
+ return;
6149
+ }
6150
+
6151
+ console.log();
6152
+ const banner = result.kind === 'liability_incident'
6153
+ ? chalk.bold.red(` delimit wrap — ${result.attestation_id || '(no attestation)'} [liability_incident]`)
6154
+ : chalk.bold.cyan(` delimit wrap — ${result.attestation_id || '(no attestation)'}`);
6155
+ console.log(banner);
6156
+ console.log(chalk.gray(` wrapped exit: ${result.wrapped_exit} mode: ${result.advisory ? 'advisory' : 'enforce'} tier: ${result.tier || 'n/a'}`));
6157
+ if (result.killed_by_timeout) {
6158
+ console.log(chalk.yellow(` ⚠ kill switch fired — wrapped command exceeded --max-time`));
6159
+ }
6160
+ if (result.gates.length) {
6161
+ console.log();
6162
+ for (const g of result.gates) {
6163
+ const tag = g.exit === undefined
6164
+ ? chalk.gray('·')
6165
+ : (g.exit === 0 ? chalk.green('✓') : chalk.red('✗'));
6166
+ const name = g.name + (g.runner ? ':' + g.runner : '') + (g.spec ? ':' + path.basename(g.spec) : '');
6167
+ const note = g.result ? ` (${g.result})` : '';
6168
+ console.log(` ${tag} ${name}${note}`);
6169
+ }
6170
+ }
6171
+ if (result.violations.length) {
6172
+ console.log();
6173
+ console.log(chalk.yellow(` ${result.violations.length} violation(s):`));
6174
+ for (const v of result.violations) console.log(chalk.yellow(` - ${v}`));
6175
+ if (result.advisory) console.log(chalk.gray(' advisory mode — wrap exit unaffected. Re-run with --enforce to block.'));
6176
+ }
6177
+ if (result.attestation_id) {
6178
+ console.log();
6179
+ console.log(chalk.gray(` attestation: ${result.attestation_path || '(not saved)'}`));
6180
+ console.log(chalk.gray(` replay: ${result.replay_url}`));
6181
+ }
6182
+ if (result.handoff_suggestion) {
6183
+ console.log();
6184
+ console.log(chalk.bold(' cross-model handoff suggestion:'));
6185
+ console.log(` ${chalk.cyan(result.handoff_suggestion.suggested_command)}`);
6186
+ console.log(chalk.gray(` (alternates: ${result.handoff_suggestion.alternates.join(', ')})`));
6187
+ }
6188
+ console.log();
6189
+ process.exit(result.exit);
6190
+ } catch (e) {
6191
+ console.error(chalk.red(`\n [wrap] ${e.message || e}\n`));
6192
+ process.exit(1);
6193
+ }
6194
+ });
6195
+
6196
+ // LED-1018 Venture #6 MVP: trust-page + ai-sbom subcommands
6197
+ // Render aggregated attestations (from `delimit wrap`) into a public static
6198
+ // trust page (HTML + JSON Feed) and a CycloneDX-AI bill of materials.
6199
+ program
6200
+ .command('trust-page')
6201
+ .description('Render attestations into a public trust page (static HTML + JSON feed)')
6202
+ .option('-d, --dir <path>', 'Attestation directory', path.join(os.homedir(), '.delimit', 'attestations'))
6203
+ .option('-o, --out <path>', 'Output directory', './trust-page')
6204
+ .option('-t, --title <title>', 'Trust page title', 'Trust Page')
6205
+ .option('--json', 'Output result as JSON', false)
6206
+ .action(async (options) => {
6207
+ const { renderTrustPage } = require('../lib/trust-page-engine');
6208
+ try {
6209
+ const result = renderTrustPage(options.dir, options.out, options.title);
6210
+ if (options.json) {
6211
+ console.log(JSON.stringify(result, null, 2));
6212
+ return;
6213
+ }
6214
+ console.log();
6215
+ console.log(chalk.bold.cyan(` delimit trust-page`));
6216
+ console.log(chalk.gray(` source: ${options.dir}`));
6217
+ console.log(chalk.gray(` output: ${result.outDir}/`));
6218
+ console.log(chalk.gray(` attestations rendered: ${result.count}`));
6219
+ console.log(chalk.gray(` feed items: ${result.feed_items} html bytes: ${result.html_bytes}`));
6220
+ console.log();
6221
+ console.log(chalk.bold(` Open: ${path.resolve(result.outDir)}/index.html`));
6222
+ console.log();
6223
+ } catch (e) {
6224
+ console.error(chalk.red(`\n [trust-page] ${e.message || e}\n`));
6225
+ process.exit(1);
6226
+ }
6227
+ });
6228
+
6229
+ program
6230
+ .command('ai-sbom')
6231
+ .description('Build a CycloneDX-AI bill of materials from attestations')
6232
+ .option('-d, --dir <path>', 'Attestation directory', path.join(os.homedir(), '.delimit', 'attestations'))
6233
+ .option('-o, --out <path>', 'Output file', './ai-sbom.json')
6234
+ .option('-n, --name <name>', 'BOM subject name', 'ai-sbom')
6235
+ .option('-v, --package-version <v>', 'BOM subject version', '1.0.0')
6236
+ .option('--json', 'Print the SBOM to stdout instead of writing to file', false)
6237
+ .action(async (options) => {
6238
+ const { buildAISBOM } = require('../lib/ai-sbom-engine');
6239
+ try {
6240
+ const { sbom, aggregate, attestation_count } = buildAISBOM(options.dir, {
6241
+ name: options.name,
6242
+ version: options.packageVersion,
6243
+ });
6244
+ if (options.json) {
6245
+ console.log(JSON.stringify(sbom, null, 2));
6246
+ return;
6247
+ }
6248
+ fs.writeFileSync(options.out, JSON.stringify(sbom, null, 2));
6249
+ console.log();
6250
+ console.log(chalk.bold.cyan(` delimit ai-sbom`));
6251
+ console.log(chalk.gray(` source: ${options.dir}`));
6252
+ console.log(chalk.gray(` output: ${path.resolve(options.out)}`));
6253
+ console.log(chalk.gray(` attestations scanned: ${attestation_count}`));
6254
+ console.log(chalk.gray(` models detected: ${aggregate.models.length}`));
6255
+ console.log(chalk.gray(` tool-call surface: ${aggregate.tool_calls.length}`));
6256
+ console.log(chalk.gray(` total gates run: ${aggregate.total_gates_run}`));
6257
+ console.log(chalk.gray(` total violations: ${aggregate.total_violations}`));
6258
+ console.log();
6259
+ } catch (e) {
6260
+ console.error(chalk.red(`\n [ai-sbom] ${e.message || e}\n`));
6261
+ process.exit(1);
6262
+ }
6263
+ });
6264
+
6116
6265
  // Hide legacy/internal commands from --help
6117
6266
  ['install', 'mode', 'status', 'policy', 'auth', 'audit',
6118
6267
  'explain-decision', 'uninstall', 'proxy', 'hook'].forEach(name => {
@@ -0,0 +1,154 @@
1
+ // lib/ai-sbom-engine.js
2
+ //
3
+ // LED-1018 Venture #6 MVP: `delimit ai-sbom` aggregation.
4
+ // Scans a directory of attestations, extracts the AI surface (models, prompts,
5
+ // tool calls, data classes), and emits a CycloneDX 1.6-shaped bill of materials
6
+ // with AI-specific fields.
7
+ //
8
+ // CycloneDX-AI schema reference: https://cyclonedx.org/capabilities/mlbom/
9
+ // Per the architecture doc, MVP aggregates what attestations already capture;
10
+ // explicit static-analysis code-walker lands in Phase 2.
11
+
12
+ const { loadAttestations } = require('./trust-page-engine');
13
+ const crypto = require('crypto');
14
+
15
+ const KNOWN_MODEL_PROVIDERS = [
16
+ // Loose matching against wrapped_command strings. Extends easily.
17
+ { pattern: /claude|anthropic/i, vendor: 'anthropic', family: 'claude' },
18
+ { pattern: /openai|gpt-\d|o1|o3/i, vendor: 'openai', family: 'gpt' },
19
+ { pattern: /gemini|vertex/i, vendor: 'google', family: 'gemini' },
20
+ { pattern: /codex/i, vendor: 'openai', family: 'codex' },
21
+ { pattern: /grok|xai/i, vendor: 'xai', family: 'grok' },
22
+ { pattern: /llama|mistral/i, vendor: 'meta-or-mistral', family: 'open-weight' },
23
+ { pattern: /cursor/i, vendor: 'cursor', family: 'cursor-agent' },
24
+ { pattern: /aider/i, vendor: 'aider', family: 'aider-agent' },
25
+ { pattern: /copilot/i, vendor: 'github', family: 'copilot' },
26
+ ];
27
+
28
+ function detectModelFromCommand(cmd) {
29
+ if (!cmd) return null;
30
+ for (const m of KNOWN_MODEL_PROVIDERS) {
31
+ if (m.pattern.test(cmd)) return { vendor: m.vendor, family: m.family };
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function aggregateAISurface(attestations) {
37
+ const models = new Map(); // key: vendor:family -> { count, first_seen, last_seen }
38
+ const toolCallCounts = new Map(); // tool name -> count
39
+ const totalAttestations = attestations.length;
40
+ let earliest = null, latest = null;
41
+ let totalGatesRun = 0, totalViolations = 0;
42
+
43
+ for (const att of attestations) {
44
+ const b = att.bundle || {};
45
+
46
+ // Model detection — prefer explicit ai_surface.models_detected, fall back to command heuristic
47
+ const explicitModels = (b.ai_surface?.models_detected) || [];
48
+ for (const m of explicitModels) {
49
+ const key = m.includes(':') ? m : `unknown:${m}`;
50
+ const [vendor, family] = key.split(':', 2);
51
+ const entry = models.get(key) || { vendor, family, count: 0, first_seen: null, last_seen: null };
52
+ entry.count += 1;
53
+ if (b.started_at) {
54
+ if (!entry.first_seen || b.started_at < entry.first_seen) entry.first_seen = b.started_at;
55
+ if (!entry.last_seen || b.started_at > entry.last_seen) entry.last_seen = b.started_at;
56
+ }
57
+ models.set(key, entry);
58
+ }
59
+ if (explicitModels.length === 0) {
60
+ const inferred = detectModelFromCommand(b.wrapped_command || '');
61
+ if (inferred) {
62
+ const key = `${inferred.vendor}:${inferred.family}`;
63
+ const entry = models.get(key) || { vendor: inferred.vendor, family: inferred.family, count: 0, first_seen: null, last_seen: null, source: 'inferred' };
64
+ entry.count += 1;
65
+ entry.source = 'inferred';
66
+ if (b.started_at) {
67
+ if (!entry.first_seen || b.started_at < entry.first_seen) entry.first_seen = b.started_at;
68
+ if (!entry.last_seen || b.started_at > entry.last_seen) entry.last_seen = b.started_at;
69
+ }
70
+ models.set(key, entry);
71
+ }
72
+ }
73
+
74
+ // Tool calls
75
+ const tools = b.ai_surface?.tool_calls || [];
76
+ for (const t of tools) toolCallCounts.set(t, (toolCallCounts.get(t) || 0) + 1);
77
+
78
+ // Timestamps
79
+ if (b.started_at) {
80
+ if (!earliest || b.started_at < earliest) earliest = b.started_at;
81
+ if (!latest || b.started_at > latest) latest = b.started_at;
82
+ }
83
+
84
+ // Governance counts
85
+ totalGatesRun += (b.governance?.gates || []).length;
86
+ totalViolations += (b.governance?.violations || []).length;
87
+ }
88
+
89
+ return {
90
+ total_attestations: totalAttestations,
91
+ total_gates_run: totalGatesRun,
92
+ total_violations: totalViolations,
93
+ models: Array.from(models.values()),
94
+ tool_calls: Array.from(toolCallCounts.entries()).map(([name, count]) => ({ name, count })),
95
+ earliest,
96
+ latest,
97
+ };
98
+ }
99
+
100
+ function renderCycloneDXAI(aggregate, { name = 'ai-sbom', version = '1.0.0' } = {}) {
101
+ const serialNumber = 'urn:uuid:' + crypto.randomUUID();
102
+
103
+ return {
104
+ bomFormat: 'CycloneDX',
105
+ specVersion: '1.6',
106
+ serialNumber,
107
+ version: 1,
108
+ metadata: {
109
+ timestamp: new Date().toISOString(),
110
+ tools: [{ vendor: 'delimit', name: 'delimit-ai-sbom', version }],
111
+ component: {
112
+ 'bom-ref': `pkg:${name}@${version}`,
113
+ type: 'application',
114
+ name,
115
+ version,
116
+ },
117
+ properties: [
118
+ { name: 'delimit:total_attestations', value: String(aggregate.total_attestations) },
119
+ { name: 'delimit:total_gates_run', value: String(aggregate.total_gates_run) },
120
+ { name: 'delimit:total_violations', value: String(aggregate.total_violations) },
121
+ { name: 'delimit:earliest_attestation', value: aggregate.earliest || '' },
122
+ { name: 'delimit:latest_attestation', value: aggregate.latest || '' },
123
+ { name: 'delimit:tool_surface', value: JSON.stringify(aggregate.tool_calls) },
124
+ ],
125
+ },
126
+ components: aggregate.models.map(m => ({
127
+ 'bom-ref': `model:${m.vendor}/${m.family}`,
128
+ type: 'machine-learning-model',
129
+ vendor: m.vendor,
130
+ name: m.family,
131
+ description: `AI model detected across ${m.count} attestations${m.source ? ` (${m.source} from command)` : ''}`,
132
+ modelCard: {
133
+ modelParameters: {
134
+ approach: { type: 'supervised' },
135
+ },
136
+ properties: [
137
+ { name: 'delimit:usage_count', value: String(m.count) },
138
+ { name: 'delimit:first_seen', value: m.first_seen || '' },
139
+ { name: 'delimit:last_seen', value: m.last_seen || '' },
140
+ ...(m.source ? [{ name: 'delimit:detection_source', value: m.source }] : []),
141
+ ],
142
+ },
143
+ })),
144
+ };
145
+ }
146
+
147
+ function buildAISBOM(attestationDir, opts = {}) {
148
+ const attestations = loadAttestations(attestationDir);
149
+ const aggregate = aggregateAISurface(attestations);
150
+ const sbom = renderCycloneDXAI(aggregate, opts);
151
+ return { sbom, aggregate, attestation_count: attestations.length };
152
+ }
153
+
154
+ module.exports = { buildAISBOM, aggregateAISurface, renderCycloneDXAI, detectModelFromCommand };
@@ -0,0 +1,179 @@
1
+ // lib/trust-page-engine.js
2
+ //
3
+ // LED-1018 Venture #6 MVP: `delimit trust-page` render.
4
+ // Scans a directory of delimit.attestation.v1 JSON files, verifies signatures,
5
+ // renders a static index.html + JSON Feed 1.1-shaped feed.json.
6
+ //
7
+ // Local-only render. Cloud sync is a Pro/Premium feature, deferred.
8
+
9
+ const crypto = require('crypto');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ function loadHmacKey() {
15
+ const keyPath = path.join(os.homedir(), '.delimit', 'wrap-hmac.key');
16
+ if (!fs.existsSync(keyPath)) return null;
17
+ return fs.readFileSync(keyPath);
18
+ }
19
+
20
+ function verifySignature(attestation, key) {
21
+ if (!key) return 'unverifiable';
22
+ try {
23
+ const canonical = JSON.stringify(attestation.bundle, Object.keys(attestation.bundle).sort());
24
+ const expected = crypto.createHmac('sha256', key).update(canonical).digest('hex');
25
+ return expected === attestation.signature ? 'verified' : 'signature_mismatch';
26
+ } catch {
27
+ return 'verify_error';
28
+ }
29
+ }
30
+
31
+ function loadAttestations(dir) {
32
+ if (!fs.existsSync(dir)) return [];
33
+ const results = [];
34
+ for (const f of fs.readdirSync(dir)) {
35
+ if (!f.startsWith('att_') || !f.endsWith('.json')) continue;
36
+ try {
37
+ const att = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
38
+ if (att.id && att.bundle) results.push(att);
39
+ } catch { /* skip corrupted */ }
40
+ }
41
+ // Reverse-chronological
42
+ results.sort((a, b) => {
43
+ const ta = a.bundle?.started_at || '';
44
+ const tb = b.bundle?.started_at || '';
45
+ return tb.localeCompare(ta);
46
+ });
47
+ return results;
48
+ }
49
+
50
+ function escapeHtml(s) {
51
+ return String(s || '').replace(/[&<>"']/g, c => ({
52
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
53
+ }[c]));
54
+ }
55
+
56
+ function redactCommand(cmd, redactLevel = 'basic') {
57
+ // MVP: basic redaction only. Strip quoted strings longer than 24 chars (likely prompt text).
58
+ if (!cmd) return '';
59
+ if (redactLevel === 'none') return cmd;
60
+ return cmd.replace(/"[^"]{24,}"/g, '"<prompt redacted>"')
61
+ .replace(/'[^']{24,}'/g, "'<prompt redacted>'");
62
+ }
63
+
64
+ function countGateResults(governance) {
65
+ const gates = governance?.gates || [];
66
+ let pass = 0, fail = 0, info = 0;
67
+ for (const g of gates) {
68
+ if (g.exit === 0) pass++;
69
+ else if (g.exit !== undefined) fail++;
70
+ else info++;
71
+ }
72
+ return { pass, fail, info, total: gates.length };
73
+ }
74
+
75
+ function renderHTML(attestations, title = 'Trust Page') {
76
+ const hmacKey = loadHmacKey();
77
+ const rows = attestations.map(att => {
78
+ const verify = verifySignature(att, hmacKey);
79
+ const b = att.bundle || {};
80
+ const counts = countGateResults(b.governance);
81
+ const violations = (b.governance?.violations || []).length;
82
+ const status = violations > 0 ? 'violations' : (counts.fail > 0 ? 'failures' : 'clean');
83
+ return ` <tr>
84
+ <td><code>${escapeHtml(att.id)}</code></td>
85
+ <td class="cmd">${escapeHtml(redactCommand(b.wrapped_command))}</td>
86
+ <td>${escapeHtml(b.started_at || '')}</td>
87
+ <td class="gates">${counts.pass}/${counts.total}</td>
88
+ <td class="v-${verify}">${verify.replace('_', ' ')}</td>
89
+ <td class="s-${status}">${status}</td>
90
+ </tr>`;
91
+ }).join('\n');
92
+
93
+ const empty = attestations.length === 0
94
+ ? ` <p class="empty">No attestations yet. Run <code>delimit wrap &lt;cmd&gt;</code> to create one.</p>`
95
+ : '';
96
+
97
+ return `<!doctype html>
98
+ <html lang="en">
99
+ <head>
100
+ <meta charset="utf-8">
101
+ <meta name="viewport" content="width=device-width,initial-scale=1">
102
+ <title>${escapeHtml(title)}</title>
103
+ <style>
104
+ :root { color-scheme: light dark; --fg:#111; --bg:#fff; --muted:#666; --ok:#087443; --warn:#b45309; --err:#b91c1c; }
105
+ @media (prefers-color-scheme: dark) { :root { --fg:#eee; --bg:#0a0a0a; --muted:#888; } }
106
+ body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; color: var(--fg); background: var(--bg); max-width: 980px; margin: 2rem auto; padding: 0 1rem; }
107
+ h1 { margin-bottom: .25rem; font-weight: 600; }
108
+ .sub { color: var(--muted); margin-bottom: 2rem; }
109
+ table { width: 100%; border-collapse: collapse; }
110
+ th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid rgba(0,0,0,.08); }
111
+ th { font-weight: 600; color: var(--muted); font-size: .82em; text-transform: uppercase; letter-spacing: .03em; }
112
+ code { font: 12px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; }
113
+ td.cmd { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
114
+ .gates { font-variant-numeric: tabular-nums; color: var(--muted); }
115
+ .v-verified { color: var(--ok); }
116
+ .v-unverifiable, .v-signature_mismatch { color: var(--warn); }
117
+ .s-clean { color: var(--ok); }
118
+ .s-violations, .s-failures { color: var(--err); }
119
+ .empty { color: var(--muted); padding: 3rem 0; text-align: center; }
120
+ footer { margin-top: 3rem; color: var(--muted); font-size: .85em; }
121
+ </style>
122
+ </head>
123
+ <body>
124
+ <h1>${escapeHtml(title)}</h1>
125
+ <p class="sub">${attestations.length} signed attestation${attestations.length === 1 ? '' : 's'} · generated ${new Date().toISOString()}</p>
126
+ ${empty || ` <table>
127
+ <thead><tr><th>ID</th><th>Command</th><th>Started</th><th>Gates</th><th>Signature</th><th>Status</th></tr></thead>
128
+ <tbody>
129
+ ${rows}
130
+ </tbody>
131
+ </table>`}
132
+ <footer>
133
+ Generated by <code>delimit trust-page</code>. <a href="feed.json">JSON Feed</a>.<br>
134
+ Each row is an <code>att_*</code> record signed with HMAC-SHA256. Schema: <code>delimit.attestation.v1</code>.
135
+ </footer>
136
+ </body>
137
+ </html>
138
+ `;
139
+ }
140
+
141
+ function renderFeed(attestations, title = 'Trust Page') {
142
+ const items = attestations.map(att => {
143
+ const b = att.bundle || {};
144
+ const verify = verifySignature(att, loadHmacKey());
145
+ return {
146
+ id: att.id,
147
+ title: redactCommand(b.wrapped_command || att.id),
148
+ content_text: `wrapped=${b.wrapped_command || ''} | exit=${b.wrapped_exit} | gates=${(b.governance?.gates || []).length} | violations=${(b.governance?.violations || []).length} | signature=${verify}`,
149
+ date_published: b.started_at,
150
+ _delimit: {
151
+ attestation_id: att.id,
152
+ signature: att.signature,
153
+ signature_alg: att.signature_alg,
154
+ wrapped_exit: b.wrapped_exit,
155
+ changed_files: b.changed_files,
156
+ governance: b.governance,
157
+ ai_surface: b.ai_surface || null,
158
+ }
159
+ };
160
+ });
161
+ return {
162
+ version: 'https://jsonfeed.org/version/1.1',
163
+ title,
164
+ description: 'Signed replayable attestations for AI-assisted merges. Schema: delimit.attestation.v1.',
165
+ items,
166
+ };
167
+ }
168
+
169
+ function renderTrustPage(attestationDir, outDir, title) {
170
+ const attestations = loadAttestations(attestationDir);
171
+ fs.mkdirSync(outDir, { recursive: true });
172
+ const html = renderHTML(attestations, title);
173
+ const feed = renderFeed(attestations, title);
174
+ fs.writeFileSync(path.join(outDir, 'index.html'), html);
175
+ fs.writeFileSync(path.join(outDir, 'feed.json'), JSON.stringify(feed, null, 2));
176
+ return { count: attestations.length, outDir, html_bytes: html.length, feed_items: feed.items.length };
177
+ }
178
+
179
+ module.exports = { renderTrustPage, loadAttestations, verifySignature, renderHTML, renderFeed };
@@ -0,0 +1,431 @@
1
+ // lib/wrap-engine.js
2
+ //
3
+ // LED-1048: `delimit wrap` — Surface 1 CLI-pipe extension
4
+ // LED-1052: Kill Switch + cross-model handoff extension
5
+ //
6
+ // Runs an arbitrary command (typically `claude -p` or `cursor` or `codex`),
7
+ // snapshots repo state before/after, runs governance gates on the diff,
8
+ // emits a signed attestation JSON + replay URL reference.
9
+ //
10
+ // Advisory-first: exit 0 unless --enforce is set AND gates fail.
11
+ // Cross-model-agnostic: the wrapped command is arbitrary, not bound to Claude.
12
+ //
13
+ // Kill Switch (LED-1052): --max-time <seconds> caps wall-clock; on SIGKILL
14
+ // the attestation emitted is typed as kind=liability_incident and includes
15
+ // a handoff_suggestion field pointing the user at an alternative producer.
16
+
17
+ const { spawn, spawnSync, execSync } = require('child_process');
18
+ const crypto = require('crypto');
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+
23
+ // ----------------------------------------------------------------------------
24
+ // Git helpers — snapshot before/after a wrapped command
25
+ // ----------------------------------------------------------------------------
26
+
27
+ function safeExec(cmd, opts = {}) {
28
+ try {
29
+ return execSync(cmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function getRepoRoot(cwd) {
36
+ return safeExec('git rev-parse --show-toplevel', { cwd }) || cwd;
37
+ }
38
+
39
+ function getCurrentHead(cwd) {
40
+ return safeExec('git rev-parse HEAD', { cwd });
41
+ }
42
+
43
+ function getDirtyFiles(cwd) {
44
+ // Don't use safeExec because .trim() mangles the leading-space porcelain format.
45
+ try {
46
+ const raw = execSync('git status --porcelain', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
47
+ // Porcelain format: XY<space><path> (XY is always 2 chars, e.g. " M", "??", "MM")
48
+ return raw.split('\n').filter(Boolean).map(l => l.slice(3));
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ function getUnifiedDiff(cwd, fromHead) {
55
+ if (!fromHead) return '';
56
+ // Diff against the snapshot: tracked changes + untracked files (as if added)
57
+ return safeExec(`git diff ${fromHead} -- .`, { cwd }) || '';
58
+ }
59
+
60
+ // ----------------------------------------------------------------------------
61
+ // Governance gate composition — reuse existing CLI subcommands where possible
62
+ // ----------------------------------------------------------------------------
63
+
64
+ function detectOpenAPISpecChanges(changedFiles, cwd) {
65
+ // Simple heuristic: files matching openapi*.yaml / openapi*.json / swagger.*
66
+ const specs = changedFiles.filter(f => {
67
+ const base = path.basename(f).toLowerCase();
68
+ return /(^openapi|\.openapi|swagger)\.(ya?ml|json)$/.test(base) || base === 'openapi.yaml';
69
+ });
70
+ return specs;
71
+ }
72
+
73
+ function runDelimitCLI(args, cwd) {
74
+ // Invoke the sibling CLI entry point directly to avoid PATH assumptions.
75
+ const cliPath = path.join(__dirname, '..', 'bin', 'delimit-cli.js');
76
+ try {
77
+ const result = spawnSync('node', [cliPath, ...args, '--json'], {
78
+ cwd,
79
+ encoding: 'utf-8',
80
+ timeout: 60000,
81
+ stdio: ['ignore', 'pipe', 'pipe'],
82
+ });
83
+ const stdout = result.stdout || '';
84
+ const stderr = result.stderr || '';
85
+ let parsed = null;
86
+ try {
87
+ // Find last JSON object in stdout (cli may print banner before)
88
+ const m = stdout.match(/\{[\s\S]*\}\s*$/);
89
+ if (m) parsed = JSON.parse(m[0]);
90
+ } catch { /* leave null */ }
91
+ return { exit: result.status ?? 1, stdout, stderr, parsed };
92
+ } catch (e) {
93
+ return { exit: 1, stdout: '', stderr: String(e), parsed: null };
94
+ }
95
+ }
96
+
97
+ function runTestSmoke(cwd) {
98
+ // Minimal heuristic: if pytest is available and tests/ exists, run it.
99
+ // If package.json has a test script, run `npm test`.
100
+ // Time-bounded. Advisory. Never the block criterion alone.
101
+ const results = [];
102
+ const pkgPath = path.join(cwd, 'package.json');
103
+ if (fs.existsSync(pkgPath)) {
104
+ try {
105
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
106
+ if (pkg.scripts && pkg.scripts.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1') {
107
+ const r = spawnSync('npm', ['test', '--silent'], { cwd, encoding: 'utf-8', timeout: 120000, stdio: ['ignore', 'pipe', 'pipe'] });
108
+ results.push({ runner: 'npm test', exit: r.status ?? 1, stdout: (r.stdout || '').slice(-2000), stderr: (r.stderr || '').slice(-1000) });
109
+ }
110
+ } catch { /* ignore */ }
111
+ }
112
+ if (fs.existsSync(path.join(cwd, 'tests')) || fs.existsSync(path.join(cwd, 'pytest.ini')) || fs.existsSync(path.join(cwd, 'pyproject.toml'))) {
113
+ const r = spawnSync('python3', ['-m', 'pytest', '--tb=short', '-q'], { cwd, encoding: 'utf-8', timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
114
+ results.push({ runner: 'pytest', exit: r.status ?? 1, stdout: (r.stdout || '').slice(-2000), stderr: (r.stderr || '').slice(-1000) });
115
+ }
116
+ return results;
117
+ }
118
+
119
+ // ----------------------------------------------------------------------------
120
+ // Attestation bundling + signing
121
+ // ----------------------------------------------------------------------------
122
+
123
+ function computeAttestationId(bundle) {
124
+ const canonical = JSON.stringify(bundle, Object.keys(bundle).sort());
125
+ const hash = crypto.createHash('sha256').update(canonical).digest('hex');
126
+ return 'att_' + hash.slice(0, 16);
127
+ }
128
+
129
+ function loadOrCreateHmacKey() {
130
+ // Local HMAC key for attestation signing.
131
+ // Cloud-sync + verifiable signature is a Pro/Premium feature (deferred MVP).
132
+ const keyPath = path.join(os.homedir(), '.delimit', 'wrap-hmac.key');
133
+ if (fs.existsSync(keyPath)) return fs.readFileSync(keyPath);
134
+ const key = crypto.randomBytes(32);
135
+ fs.mkdirSync(path.dirname(keyPath), { recursive: true });
136
+ fs.writeFileSync(keyPath, key, { mode: 0o600 });
137
+ return key;
138
+ }
139
+
140
+ function signAttestation(bundle) {
141
+ const key = loadOrCreateHmacKey();
142
+ const canonical = JSON.stringify(bundle, Object.keys(bundle).sort());
143
+ return crypto.createHmac('sha256', key).update(canonical).digest('hex');
144
+ }
145
+
146
+ // ----------------------------------------------------------------------------
147
+ // Quota enforcement — free tier 3 lifetime, Pro unlimited
148
+ // ----------------------------------------------------------------------------
149
+
150
+ function checkQuota() {
151
+ const counterPath = path.join(os.homedir(), '.delimit', 'wrap-lifetime-count');
152
+ const licensePath = path.join(os.homedir(), '.delimit', 'license.json');
153
+ let tier = 'free';
154
+ try {
155
+ if (fs.existsSync(licensePath)) {
156
+ const lic = JSON.parse(fs.readFileSync(licensePath, 'utf-8'));
157
+ if (lic.valid && ['pro', 'premium', 'enterprise'].includes(lic.tier)) {
158
+ tier = lic.tier;
159
+ }
160
+ }
161
+ } catch { /* treat as free */ }
162
+ if (tier !== 'free') return { ok: true, tier, count: null };
163
+ let count = 0;
164
+ try {
165
+ if (fs.existsSync(counterPath)) count = parseInt(fs.readFileSync(counterPath, 'utf-8').trim(), 10) || 0;
166
+ } catch { /* start at 0 */ }
167
+ return { ok: count < 3, tier: 'free', count, limit: 3 };
168
+ }
169
+
170
+ function incrementQuota() {
171
+ const counterPath = path.join(os.homedir(), '.delimit', 'wrap-lifetime-count');
172
+ let count = 0;
173
+ try {
174
+ if (fs.existsSync(counterPath)) count = parseInt(fs.readFileSync(counterPath, 'utf-8').trim(), 10) || 0;
175
+ } catch {}
176
+ count += 1;
177
+ fs.mkdirSync(path.dirname(counterPath), { recursive: true });
178
+ fs.writeFileSync(counterPath, String(count));
179
+ return count;
180
+ }
181
+
182
+ // ----------------------------------------------------------------------------
183
+ // Persistence — save attestation to local ledger
184
+ // ----------------------------------------------------------------------------
185
+
186
+ function saveAttestation(att) {
187
+ const dir = path.join(os.homedir(), '.delimit', 'attestations');
188
+ fs.mkdirSync(dir, { recursive: true });
189
+ const file = path.join(dir, `${att.id}.json`);
190
+ fs.writeFileSync(file, JSON.stringify(att, null, 2));
191
+ return file;
192
+ }
193
+
194
+ function replayUrl(attId) {
195
+ // Public replay surface is served by app.delimit.ai (reuses the trust-page
196
+ // pattern from LED-1018). For MVP, just returns the URL; rendering + upload
197
+ // is the Pro-tier feature and not part of this MVP.
198
+ return `https://delimit.ai/att/${attId}`;
199
+ }
200
+
201
+ // ----------------------------------------------------------------------------
202
+ // Main wrap flow
203
+ // ----------------------------------------------------------------------------
204
+
205
+ // LED-1052: map a wrapped command's base binary to a handoff suggestion
206
+ // for the remaining producers. Advisory only — prints the command a user
207
+ // could run to resume with a different model.
208
+ function suggestHandoff(rawCmd) {
209
+ const bin = (rawCmd && rawCmd[0]) || '';
210
+ const base = path.basename(bin).toLowerCase();
211
+ const prompt = extractPromptArg(rawCmd);
212
+ const fallbacks = {
213
+ claude: ['codex', 'gemini', 'cursor'],
214
+ 'claude-code': ['codex', 'gemini', 'cursor'],
215
+ cursor: ['claude', 'codex', 'gemini'],
216
+ 'cursor-cli': ['claude', 'codex', 'gemini'],
217
+ aider: ['claude', 'codex', 'gemini'],
218
+ codex: ['claude', 'gemini', 'cursor'],
219
+ gemini: ['claude', 'codex', 'cursor'],
220
+ };
221
+ const key = Object.keys(fallbacks).find(k => base.includes(k));
222
+ if (!key) return null;
223
+ const alt = fallbacks[key][0];
224
+ const altPrompt = prompt || '<your-goal>';
225
+ return {
226
+ kill_source: key,
227
+ handoff_target: alt,
228
+ suggested_command: `delimit wrap -- ${alt} -p "${altPrompt}"`,
229
+ alternates: fallbacks[key],
230
+ };
231
+ }
232
+
233
+ function extractPromptArg(rawCmd) {
234
+ // Best-effort scrape: -p <prompt> or --prompt <prompt>
235
+ if (!rawCmd) return null;
236
+ for (let i = 0; i < rawCmd.length - 1; i++) {
237
+ if (rawCmd[i] === '-p' || rawCmd[i] === '--prompt') return rawCmd[i + 1];
238
+ }
239
+ return null;
240
+ }
241
+
242
+ // LED-1052: spawn a child with wall-clock timeout + SIGKILL on breach.
243
+ // Returns { status, killed_by_timeout, ms }.
244
+ function spawnWithKillSwitch(bin, args, spawnOpts, maxTimeSeconds) {
245
+ return new Promise((resolve) => {
246
+ const child = spawn(bin, args, { ...spawnOpts, stdio: 'inherit' });
247
+ const started = Date.now();
248
+ let killed = false;
249
+ const timer = maxTimeSeconds && maxTimeSeconds > 0
250
+ ? setTimeout(() => {
251
+ killed = true;
252
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
253
+ }, maxTimeSeconds * 1000)
254
+ : null;
255
+ child.on('close', (code, signal) => {
256
+ if (timer) clearTimeout(timer);
257
+ resolve({
258
+ status: (code !== null ? code : (signal === 'SIGKILL' ? 137 : 1)),
259
+ killed_by_timeout: killed,
260
+ signal,
261
+ ms: Date.now() - started,
262
+ });
263
+ });
264
+ child.on('error', (err) => {
265
+ if (timer) clearTimeout(timer);
266
+ resolve({ status: 1, killed_by_timeout: false, error: String(err), ms: Date.now() - started });
267
+ });
268
+ });
269
+ }
270
+
271
+ async function runWrap(rawCmd, options = {}) {
272
+ const {
273
+ enforce = false,
274
+ deliberate = false,
275
+ attest = true,
276
+ cwd = process.cwd(),
277
+ maxTimeSeconds = 0, // LED-1052: 0 disables kill switch
278
+ } = options;
279
+
280
+ const repoRoot = getRepoRoot(cwd);
281
+ const isGitRepo = !!safeExec('git rev-parse --is-inside-work-tree', { cwd: repoRoot });
282
+
283
+ // Quota check (only if attestation will be emitted)
284
+ let quotaInfo = null;
285
+ if (attest) {
286
+ quotaInfo = checkQuota();
287
+ if (!quotaInfo.ok) {
288
+ return {
289
+ exit: 1,
290
+ error: 'quota_exceeded',
291
+ message: `Free tier: ${quotaInfo.count}/${quotaInfo.limit} lifetime attestations used. Upgrade to Pro ($10/mo) for unlimited — visit https://delimit.ai/pricing`,
292
+ tier: quotaInfo.tier,
293
+ };
294
+ }
295
+ }
296
+
297
+ // Snapshot before
298
+ const beforeHead = isGitRepo ? getCurrentHead(repoRoot) : null;
299
+ const beforeDirty = isGitRepo ? getDirtyFiles(repoRoot) : [];
300
+ const startedAt = new Date().toISOString();
301
+
302
+ // Execute the wrapped command
303
+ // The wrapped command runs in the user's shell so `claude -p "..."` / `cursor edit` / etc. work natively.
304
+ // LED-1052: if maxTimeSeconds > 0, use async spawnWithKillSwitch; otherwise spawnSync for back-compat.
305
+ let wrappedExit;
306
+ let killedByTimeout = false;
307
+ let killSignal = null;
308
+ if (maxTimeSeconds > 0) {
309
+ const res = await spawnWithKillSwitch(rawCmd[0], rawCmd.slice(1), { cwd: repoRoot, shell: false }, maxTimeSeconds);
310
+ wrappedExit = res.status;
311
+ killedByTimeout = res.killed_by_timeout;
312
+ killSignal = res.signal || null;
313
+ } else {
314
+ const child = spawnSync(rawCmd[0], rawCmd.slice(1), { cwd: repoRoot, stdio: 'inherit', shell: false });
315
+ wrappedExit = child.status ?? 1;
316
+ }
317
+ const completedAt = new Date().toISOString();
318
+
319
+ // Snapshot after
320
+ const afterHead = isGitRepo ? getCurrentHead(repoRoot) : null;
321
+ const afterDirty = isGitRepo ? getDirtyFiles(repoRoot) : [];
322
+ const changedFiles = isGitRepo
323
+ ? Array.from(new Set([...beforeDirty, ...afterDirty]))
324
+ : [];
325
+
326
+ // Governance chain
327
+ const governance = { gates: [], violations: [], advisory: !enforce };
328
+
329
+ // 1) OpenAPI spec changes → delimit lint / diff
330
+ const specChanges = detectOpenAPISpecChanges(changedFiles, repoRoot);
331
+ if (specChanges.length > 0) {
332
+ governance.gates.push({ name: 'openapi_detect', result: 'ran', specs: specChanges });
333
+ // Try running delimit lint on each (zero-spec mode against baseline)
334
+ for (const spec of specChanges) {
335
+ const lintResult = runDelimitCLI(['lint'], path.dirname(path.join(repoRoot, spec)));
336
+ governance.gates.push({
337
+ name: 'delimit_lint',
338
+ spec,
339
+ exit: lintResult.exit,
340
+ summary: (lintResult.stdout || '').slice(-500),
341
+ });
342
+ if (lintResult.exit !== 0) governance.violations.push(`lint failed on ${spec}`);
343
+ }
344
+ }
345
+
346
+ // 2) Test smoke
347
+ const testResults = runTestSmoke(repoRoot);
348
+ for (const t of testResults) {
349
+ governance.gates.push({ name: 'test_smoke', runner: t.runner, exit: t.exit });
350
+ if (t.exit !== 0) governance.violations.push(`${t.runner} failed`);
351
+ }
352
+ if (testResults.length === 0) {
353
+ governance.gates.push({ name: 'test_smoke', result: 'no_tests_detected' });
354
+ }
355
+
356
+ // 3) Multi-model deliberate (optional)
357
+ if (deliberate) {
358
+ governance.gates.push({ name: 'deliberate', result: 'deferred', note: 'use `delimit deliberate` standalone for multi-model verdict — v1 wrap emits local attestation only' });
359
+ }
360
+
361
+ // Build attestation bundle
362
+ // LED-1052: if killed by timeout, attestation is typed as liability_incident
363
+ const kind = killedByTimeout ? 'liability_incident' : 'merge_attestation';
364
+ const handoffSuggestion = killedByTimeout ? suggestHandoff(rawCmd) : null;
365
+ const bundle = {
366
+ schema: 'delimit.attestation.v1',
367
+ kind,
368
+ wrapped_command: rawCmd.join(' '),
369
+ repo_root: repoRoot,
370
+ is_git_repo: isGitRepo,
371
+ before_head: beforeHead,
372
+ after_head: afterHead,
373
+ started_at: startedAt,
374
+ completed_at: completedAt,
375
+ wrapped_exit: wrappedExit,
376
+ changed_files: changedFiles,
377
+ governance,
378
+ delimit_wrap_version: '1.1.0',
379
+ ...(killedByTimeout ? {
380
+ kill_switch: {
381
+ kind: 'timeout',
382
+ max_time_seconds: maxTimeSeconds,
383
+ signal: killSignal,
384
+ handoff_suggestion: handoffSuggestion,
385
+ },
386
+ } : {}),
387
+ };
388
+ const attId = computeAttestationId(bundle);
389
+ const signature = signAttestation(bundle);
390
+ const attestation = {
391
+ id: attId,
392
+ bundle,
393
+ signature,
394
+ signature_alg: 'HMAC-SHA256',
395
+ };
396
+
397
+ let filePath = null;
398
+ if (attest) {
399
+ filePath = saveAttestation(attestation);
400
+ if (quotaInfo && quotaInfo.tier === 'free') incrementQuota();
401
+ }
402
+
403
+ const hasViolations = governance.violations.length > 0;
404
+ const shouldFail = enforce && hasViolations;
405
+
406
+ return {
407
+ exit: shouldFail ? 2 : wrappedExit,
408
+ attestation_id: attId,
409
+ attestation_path: filePath,
410
+ replay_url: replayUrl(attId),
411
+ kind,
412
+ violations: governance.violations,
413
+ gates: governance.gates,
414
+ wrapped_exit: wrappedExit,
415
+ advisory: !enforce,
416
+ tier: quotaInfo ? quotaInfo.tier : null,
417
+ // LED-1052: kill-switch metadata surfaced at the top level for CLI rendering
418
+ ...(killedByTimeout ? {
419
+ killed_by_timeout: true,
420
+ handoff_suggestion: handoffSuggestion,
421
+ } : {}),
422
+ };
423
+ }
424
+
425
+ module.exports = {
426
+ runWrap,
427
+ computeAttestationId,
428
+ signAttestation,
429
+ checkQuota,
430
+ replayUrl,
431
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delimit-cli",
3
3
  "mcpName": "io.github.delimit-ai/delimit-mcp-server",
4
- "version": "4.2.0",
4
+ "version": "4.3.1",
5
5
  "description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -1,64 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Delimit Security Skill for Codex CLI
4
- *
5
- * Validates that Codex-generated code doesn't introduce security anti-patterns.
6
- * Runs as a security validation skill.
7
- */
8
-
9
- const fs = require('fs');
10
- const path = require('path');
11
-
12
- const SECURITY_PATTERNS = [
13
- { pattern: /eval\s*\(/g, severity: 'high', message: 'eval() usage detected — potential code injection' },
14
- { pattern: /exec\s*\(/g, severity: 'medium', message: 'exec() usage — verify input sanitization' },
15
- { pattern: /shell\s*=\s*True/g, severity: 'high', message: 'subprocess with shell=True — command injection risk' },
16
- { pattern: /dangerouslySetInnerHTML/g, severity: 'medium', message: 'dangerouslySetInnerHTML — XSS risk' },
17
- { pattern: /password\s*=\s*["'][^"']+["']/gi, severity: 'high', message: 'Hardcoded password detected' },
18
- { pattern: /api[_-]?key\s*=\s*["'][A-Za-z0-9]{10,}["']/gi, severity: 'high', message: 'Hardcoded API key detected' },
19
- ];
20
-
21
- function checkSecurity(context) {
22
- const code = context.code || context.content || '';
23
- if (!code) return { status: 'clean', findings: [] };
24
-
25
- const findings = [];
26
- for (const { pattern, severity, message } of SECURITY_PATTERNS) {
27
- const matches = code.match(pattern);
28
- if (matches) {
29
- findings.push({ severity, message, count: matches.length });
30
- }
31
- }
32
-
33
- // Audit
34
- try {
35
- const auditDir = path.join(process.env.HOME || '', '.delimit', 'audit');
36
- fs.mkdirSync(auditDir, { recursive: true });
37
- const record = {
38
- timestamp: new Date().toISOString(),
39
- source: 'codex-security',
40
- findings_count: findings.length,
41
- high_count: findings.filter(f => f.severity === 'high').length,
42
- };
43
- const auditFile = path.join(auditDir, `${new Date().toISOString().split('T')[0]}.jsonl`);
44
- fs.appendFileSync(auditFile, JSON.stringify(record) + '\n');
45
- } catch {}
46
-
47
- const hasHigh = findings.some(f => f.severity === 'high');
48
- return {
49
- status: hasHigh ? 'flagged' : findings.length > 0 ? 'warnings' : 'clean',
50
- findings,
51
- };
52
- }
53
-
54
- const context = process.argv[2] ? JSON.parse(process.argv[2]) : {};
55
- const result = checkSecurity(context);
56
-
57
- if (result.status === 'flagged') {
58
- for (const f of result.findings) {
59
- console.error(`[Delimit Security] ${f.severity.toUpperCase()}: ${f.message}`);
60
- }
61
- process.exit(1);
62
- }
63
-
64
- process.exit(0);
@@ -1,78 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Delimit Governance Skill for Codex CLI
4
- *
5
- * Runs as a validation skill triggered on pre-code-generation and pre-suggestion.
6
- * Checks governance state and policy compliance before Codex executes actions.
7
- */
8
-
9
- const fs = require('fs');
10
- const path = require('path');
11
-
12
- const DELIMIT_HOME = path.join(process.env.HOME || '', '.delimit');
13
- const MODE_FILE = path.join(DELIMIT_HOME, 'enforcement_mode');
14
-
15
- function getMode() {
16
- try {
17
- return fs.readFileSync(MODE_FILE, 'utf-8').trim();
18
- } catch {
19
- return 'guarded'; // Default
20
- }
21
- }
22
-
23
- function checkGovernance(context) {
24
- const mode = getMode();
25
- const warnings = [];
26
-
27
- // Check if governance is initialized
28
- const policiesFile = path.join(process.cwd(), '.delimit', 'policies.yml');
29
- if (!fs.existsSync(policiesFile)) {
30
- warnings.push('No .delimit/policies.yml — run: delimit init');
31
- }
32
-
33
- // Check for sensitive file access
34
- const sensitivePatterns = ['.env', 'credentials', '.ssh', 'secrets'];
35
- const target = context.target || context.file || '';
36
- for (const pattern of sensitivePatterns) {
37
- if (target.includes(pattern)) {
38
- if (mode === 'enforce') {
39
- return { status: 'blocked', reason: `Access to sensitive path: ${target}` };
40
- }
41
- warnings.push(`Accessing sensitive path: ${target}`);
42
- }
43
- }
44
-
45
- // Audit log
46
- try {
47
- const auditDir = path.join(DELIMIT_HOME, 'audit');
48
- fs.mkdirSync(auditDir, { recursive: true });
49
- const record = {
50
- timestamp: new Date().toISOString(),
51
- source: 'codex-skill',
52
- mode,
53
- context: typeof context === 'object' ? JSON.stringify(context).slice(0, 200) : String(context).slice(0, 200),
54
- warnings,
55
- };
56
- const auditFile = path.join(auditDir, `${new Date().toISOString().split('T')[0]}.jsonl`);
57
- fs.appendFileSync(auditFile, JSON.stringify(record) + '\n');
58
- } catch {}
59
-
60
- return { status: 'allowed', mode, warnings };
61
- }
62
-
63
- // Entry point — read context from stdin or args
64
- const context = process.argv[2] ? JSON.parse(process.argv[2]) : {};
65
- const result = checkGovernance(context);
66
-
67
- if (result.status === 'blocked') {
68
- console.error(`[Delimit] BLOCKED: ${result.reason}`);
69
- process.exit(1);
70
- }
71
-
72
- if (result.warnings.length > 0) {
73
- for (const w of result.warnings) {
74
- console.error(`[Delimit] Warning: ${w}`);
75
- }
76
- }
77
-
78
- process.exit(0);
@@ -1,73 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Delimit Governance Rules for Cursor
4
- *
5
- * Cursor doesn't have a hook system like Claude Code or Codex,
6
- * so governance enforcement happens server-side via MCP tool calls.
7
- * This adapter manages the .cursorrules and .cursor/rules/ files
8
- * that guide Cursor's behavior.
9
- */
10
-
11
- const fs = require('fs');
12
- const path = require('path');
13
-
14
- // LED-213: Import canonical template for cross-model parity
15
- const { getDelimitSection } = require('../lib/delimit-template');
16
-
17
- const HOME = process.env.HOME || '';
18
- const CURSOR_DIR = path.join(HOME, '.cursor');
19
- const CURSOR_RULES_DIR = path.join(CURSOR_DIR, 'rules');
20
- const CURSORRULES_FILE = path.join(HOME, '.cursorrules');
21
-
22
- /**
23
- * Install Delimit governance rules into Cursor.
24
- * Creates both .cursorrules (legacy) and .cursor/rules/delimit.md (new).
25
- */
26
- function installRules(version) {
27
- const rules = getDelimitRules(version);
28
-
29
- // Install to .cursor/rules/delimit.md (new location, Cursor 0.45+)
30
- if (fs.existsSync(CURSOR_DIR)) {
31
- fs.mkdirSync(CURSOR_RULES_DIR, { recursive: true });
32
- const rulesFile = path.join(CURSOR_RULES_DIR, 'delimit.md');
33
- fs.writeFileSync(rulesFile, rules);
34
- }
35
-
36
- return { installed: true, paths: [CURSORRULES_FILE, path.join(CURSOR_RULES_DIR, 'delimit.md')] };
37
- }
38
-
39
- /**
40
- * Remove Delimit rules from Cursor.
41
- */
42
- function uninstallRules() {
43
- const removed = [];
44
-
45
- // Remove from .cursor/rules/
46
- const rulesFile = path.join(CURSOR_RULES_DIR, 'delimit.md');
47
- if (fs.existsSync(rulesFile)) {
48
- fs.unlinkSync(rulesFile);
49
- removed.push(rulesFile);
50
- }
51
-
52
- return { removed };
53
- }
54
-
55
- function getDelimitRules(version) {
56
- // LED-213: Use canonical Consensus 123 template for Cursor parity
57
- return getDelimitSection();
58
- }
59
-
60
- module.exports = { installRules, uninstallRules, getDelimitRules };
61
-
62
- // CLI entry point
63
- if (require.main === module) {
64
- const action = process.argv[2] || 'install';
65
- const version = process.argv[3] || '3.11.9';
66
- if (action === 'install') {
67
- const result = installRules(version);
68
- console.log(`Installed Delimit rules to Cursor: ${result.paths.join(', ')}`);
69
- } else if (action === 'uninstall') {
70
- const result = uninstallRules();
71
- console.log(`Removed: ${result.removed.join(', ') || 'nothing to remove'}`);
72
- }
73
- }