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 +65 -3
- package/bin/delimit-cli.js +149 -0
- package/lib/ai-sbom-engine.js +154 -0
- package/lib/trust-page-engine.js +179 -0
- package/lib/wrap-engine.js +431 -0
- package/package.json +1 -1
- package/adapters/codex-security.js +0 -64
- package/adapters/codex-skill.js +0 -78
- package/adapters/cursor-rules.js +0 -73
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
|
[](https://www.npmjs.com/package/delimit-cli)
|
|
19
|
-
[](https://delimit.ai)
|
|
19
|
+
[](https://github.com/delimit-ai/delimit-mcp-server)
|
|
21
20
|
[](https://github.com/marketplace/actions/delimit-api-governance)
|
|
22
21
|
[](https://opensource.org/licenses/MIT)
|
|
23
22
|
[](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
|
-
- **
|
|
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
|
package/bin/delimit-cli.js
CHANGED
|
@@ -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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
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 <cmd></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.
|
|
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);
|
package/adapters/codex-skill.js
DELETED
|
@@ -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);
|
package/adapters/cursor-rules.js
DELETED
|
@@ -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
|
-
}
|