@i4ctime/q-ring 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,6 +138,9 @@ qring entangle API_KEY API_KEY_BACKUP
138
138
 
139
139
  # Now updating API_KEY also updates API_KEY_BACKUP
140
140
  qring set API_KEY "new-value"
141
+
142
+ # Unlink entangled secrets
143
+ qring disentangle API_KEY API_KEY_BACKUP
141
144
  ```
142
145
 
143
146
  ### Tunneling — Ephemeral Secrets
@@ -170,6 +173,158 @@ cat bundle.txt | qring teleport unpack
170
173
  qring teleport unpack <bundle> --dry-run
171
174
  ```
172
175
 
176
+ ### Import — Bulk Secret Ingestion
177
+
178
+ Import secrets from `.env` files directly into q-ring. Supports standard dotenv syntax including comments, quoted values, and escape sequences.
179
+
180
+ ```bash
181
+ # Import all secrets from a .env file
182
+ qring import .env
183
+
184
+ # Import to project scope, skipping existing keys
185
+ qring import .env --project --skip-existing
186
+
187
+ # Preview what would be imported
188
+ qring import .env --dry-run
189
+ ```
190
+
191
+ ### Selective Export
192
+
193
+ Export only the secrets you need using key names or tag filters.
194
+
195
+ ```bash
196
+ # Export specific keys
197
+ qring export --keys "API_KEY,DB_PASS,REDIS_URL"
198
+
199
+ # Export by tag
200
+ qring export --tags "backend"
201
+
202
+ # Combine with format
203
+ qring export --keys "API_KEY,DB_PASS" --format json
204
+ ```
205
+
206
+ ### Secret Search and Filtering
207
+
208
+ Filter `qring list` output by tag, expiry state, or key pattern.
209
+
210
+ ```bash
211
+ # Filter by tag
212
+ qring list --tag backend
213
+
214
+ # Show only expired secrets
215
+ qring list --expired
216
+
217
+ # Show only stale secrets (75%+ decay)
218
+ qring list --stale
219
+
220
+ # Glob pattern on key name
221
+ qring list --filter "API_*"
222
+ ```
223
+
224
+ ### Project Secret Manifest
225
+
226
+ Declare required secrets in `.q-ring.json` and validate project readiness with a single command.
227
+
228
+ ```bash
229
+ # Validate project secrets against the manifest
230
+ qring check
231
+
232
+ # See which secrets are present, missing, expired, or stale
233
+ qring check --project-path /path/to/project
234
+ ```
235
+
236
+ ### Env File Sync
237
+
238
+ Generate a `.env` file from the project manifest, resolving each key from q-ring with environment-aware superposition collapse.
239
+
240
+ ```bash
241
+ # Generate to stdout
242
+ qring env:generate
243
+
244
+ # Write to a file
245
+ qring env:generate --output .env
246
+
247
+ # Force a specific environment
248
+ qring env:generate --env staging --output .env.staging
249
+ ```
250
+
251
+ ### Secret Liveness Validation
252
+
253
+ Test if a secret is actually valid with its target service. q-ring auto-detects the provider from key prefixes (`sk-` → OpenAI, `ghp_` → GitHub, etc.) or accepts an explicit provider name.
254
+
255
+ ```bash
256
+ # Validate a single secret
257
+ qring validate OPENAI_API_KEY
258
+
259
+ # Force a specific provider
260
+ qring validate SOME_KEY --provider stripe
261
+
262
+ # Validate all secrets with detectable providers
263
+ qring validate --all
264
+
265
+ # Only validate manifest-declared secrets
266
+ qring validate --all --manifest
267
+
268
+ # List available providers
269
+ qring validate --list-providers
270
+ ```
271
+
272
+ **Built-in providers:** OpenAI, Stripe, GitHub, AWS (format check), Generic HTTP.
273
+
274
+ Output:
275
+
276
+ ```
277
+ ✓ OPENAI_API_KEY valid (openai, 342ms)
278
+ ✗ STRIPE_KEY invalid (stripe, 128ms) — API key has been revoked
279
+ ⚠ AWS_ACCESS_KEY error (aws, 10002ms) — network timeout
280
+ ○ DATABASE_URL unknown — no provider detected
281
+ ```
282
+
283
+ ### Hooks — Callbacks on Secret Change
284
+
285
+ Register webhooks, shell commands, or process signals that fire when secrets are created, updated, or deleted. Supports key matching, glob patterns, tag filtering, and scope constraints.
286
+
287
+ ```bash
288
+ # Run a shell command when a secret changes
289
+ qring hook add --key DB_PASS --exec "docker restart app"
290
+
291
+ # POST to a webhook on any write/delete
292
+ qring hook add --key API_KEY --url "https://hooks.example.com/rotate"
293
+
294
+ # Trigger on all secrets tagged "backend"
295
+ qring hook add --tag backend --exec "pm2 restart all"
296
+
297
+ # Signal a process when DB secrets change
298
+ qring hook add --key-pattern "DB_*" --signal-target "node"
299
+
300
+ # List all hooks
301
+ qring hook list
302
+
303
+ # Remove a hook
304
+ qring hook remove <id>
305
+
306
+ # Enable/disable
307
+ qring hook enable <id>
308
+ qring hook disable <id>
309
+
310
+ # Dry-run test a hook
311
+ qring hook test <id>
312
+ ```
313
+
314
+ Hooks are fire-and-forget: a failing hook never blocks secret operations. The hook registry is stored at `~/.config/q-ring/hooks.json`.
315
+
316
+ ### Configurable Rotation
317
+
318
+ Set a rotation format per secret so the agent auto-rotates with the correct value shape.
319
+
320
+ ```bash
321
+ # Store a secret with rotation format metadata
322
+ qring set STRIPE_KEY "sk-..." --rotation-format api-key --rotation-prefix "sk-"
323
+
324
+ # Store a password with password rotation format
325
+ qring set DB_PASS "..." --rotation-format password
326
+ ```
327
+
173
328
  ### Agent Mode — Autonomous Monitoring
174
329
 
175
330
  A background daemon that continuously monitors secret health, detects anomalies, and optionally auto-rotates expired secrets.
@@ -204,17 +359,21 @@ qring status --no-open
204
359
 
205
360
  ## MCP Server
206
361
 
207
- q-ring includes a full MCP server with 20 tools for AI agent integration.
362
+ q-ring includes a full MCP server with 31 tools for AI agent integration.
208
363
 
209
364
  ### Core Tools
210
365
 
211
366
  | Tool | Description |
212
367
  |------|-------------|
213
368
  | `get_secret` | Retrieve with superposition collapse + observer logging |
214
- | `list_secrets` | List keys with quantum metadata (never exposes values) |
215
- | `set_secret` | Store with optional TTL, env state, tags |
369
+ | `list_secrets` | List keys with quantum metadata, filterable by tag/expiry/pattern |
370
+ | `set_secret` | Store with optional TTL, env state, tags, rotation format |
216
371
  | `delete_secret` | Remove a secret |
217
372
  | `has_secret` | Boolean check (respects decay) |
373
+ | `export_secrets` | Export as .env/JSON with optional key and tag filters |
374
+ | `import_dotenv` | Parse and import secrets from .env content |
375
+ | `check_project` | Validate project secrets against `.q-ring.json` manifest |
376
+ | `env_generate` | Generate .env content from the project manifest |
218
377
 
219
378
  ### Quantum Tools
220
379
 
@@ -224,6 +383,7 @@ q-ring includes a full MCP server with 20 tools for AI agent integration.
224
383
  | `detect_environment` | Wavefunction collapse — detect current env context |
225
384
  | `generate_secret` | Quantum noise — generate and optionally save secrets |
226
385
  | `entangle_secrets` | Link two secrets for synchronized rotation |
386
+ | `disentangle_secrets` | Remove entanglement between two secrets |
227
387
 
228
388
  ### Tunneling Tools
229
389
 
@@ -241,6 +401,21 @@ q-ring includes a full MCP server with 20 tools for AI agent integration.
241
401
  | `teleport_pack` | Encrypt secrets into a portable bundle |
242
402
  | `teleport_unpack` | Decrypt and import a bundle |
243
403
 
404
+ ### Validation Tools
405
+
406
+ | Tool | Description |
407
+ |------|-------------|
408
+ | `validate_secret` | Test if a secret is valid with its target service (OpenAI, Stripe, GitHub, etc.) |
409
+ | `list_providers` | List all available validation providers |
410
+
411
+ ### Hook Tools
412
+
413
+ | Tool | Description |
414
+ |------|-------------|
415
+ | `register_hook` | Register a shell/HTTP/signal callback on secret changes |
416
+ | `list_hooks` | List all registered hooks with match criteria and status |
417
+ | `remove_hook` | Remove a registered hook by ID |
418
+
244
419
  ### Observer & Health Tools
245
420
 
246
421
  | Tool | Description |
@@ -316,13 +491,16 @@ qring CLI ─────┐
316
491
  MCP Server ────┘ │
317
492
  ├── Envelope (quantum metadata)
318
493
  ├── Scope Resolver (global / project)
319
- ├── Collapse (env detection)
494
+ ├── Collapse (env detection + branchMap globs)
320
495
  ├── Observer (audit log)
321
496
  ├── Noise (secret generation)
322
497
  ├── Entanglement (cross-secret linking)
498
+ ├── Validate (provider-based liveness checks)
499
+ ├── Hooks (shell/HTTP/signal callbacks)
500
+ ├── Import (.env file ingestion)
323
501
  ├── Tunnel (ephemeral in-memory)
324
502
  ├── Teleport (encrypted sharing)
325
- ├── Agent (autonomous monitor)
503
+ ├── Agent (autonomous monitor + rotation)
326
504
  └── Dashboard (live status via SSE)
327
505
  ```
328
506
 
@@ -337,11 +515,23 @@ Optional per-project configuration:
337
515
  "branchMap": {
338
516
  "main": "prod",
339
517
  "develop": "dev",
340
- "staging": "staging"
518
+ "staging": "staging",
519
+ "release/*": "staging",
520
+ "feature/*": "dev"
521
+ },
522
+ "secrets": {
523
+ "OPENAI_API_KEY": { "required": true, "description": "OpenAI API key", "format": "api-key", "prefix": "sk-", "provider": "openai" },
524
+ "DATABASE_URL": { "required": true, "description": "Postgres connection string", "validationUrl": "https://api.example.com/health" },
525
+ "SENTRY_DSN": { "required": false, "description": "Sentry error tracking" }
341
526
  }
342
527
  }
343
528
  ```
344
529
 
530
+ - **`branchMap`** supports glob patterns with `*` wildcards (e.g., `release/*` matches `release/v1.0`)
531
+ - **`secrets`** declares the project's required secrets — use `qring check` to validate, `qring env:generate` to produce a `.env` file
532
+ - **`provider`** associates a liveness validation provider with a secret (e.g., `"openai"`, `"stripe"`, `"github"`) — use `qring validate` to test
533
+ - **`validationUrl`** configures the generic HTTP provider's endpoint for custom validation
534
+
345
535
  ## 📜 License
346
536
 
347
537
  [AGPL-3.0](LICENSE) - Free to use, modify, and share. Any derivative work or hosted service must release its source code under the same license.
@@ -20,7 +20,10 @@ function createEnvelope(value, opts) {
20
20
  description: opts?.description,
21
21
  tags: opts?.tags,
22
22
  entangled: opts?.entangled,
23
- accessCount: 0
23
+ accessCount: 0,
24
+ rotationFormat: opts?.rotationFormat,
25
+ rotationPrefix: opts?.rotationPrefix,
26
+ provider: opts?.provider
24
27
  }
25
28
  };
26
29
  }
@@ -173,7 +176,7 @@ function collapseEnvironment(ctx = {}) {
173
176
  const branch = detectGitBranch(ctx.projectPath);
174
177
  if (branch) {
175
178
  const branchMap = { ...BRANCH_ENV_MAP, ...config?.branchMap };
176
- const mapped = branchMap[branch];
179
+ const mapped = branchMap[branch] ?? matchGlob(branchMap, branch);
177
180
  if (mapped) {
178
181
  return { env: mapped, source: "git-branch" };
179
182
  }
@@ -183,6 +186,16 @@ function collapseEnvironment(ctx = {}) {
183
186
  }
184
187
  return null;
185
188
  }
189
+ function matchGlob(branchMap, branch) {
190
+ for (const [pattern, env] of Object.entries(branchMap)) {
191
+ if (!pattern.includes("*")) continue;
192
+ const regex = new RegExp(
193
+ "^" + pattern.replace(/\*/g, ".*") + "$"
194
+ );
195
+ if (regex.test(branch)) return env;
196
+ }
197
+ return void 0;
198
+ }
186
199
  function mapEnvName(raw) {
187
200
  const lower = raw.toLowerCase();
188
201
  if (lower === "production") return "prod";
@@ -323,6 +336,13 @@ function entangle(source, target) {
323
336
  saveRegistry(registry);
324
337
  }
325
338
  }
339
+ function disentangle(source, target) {
340
+ const registry = loadRegistry();
341
+ registry.pairs = registry.pairs.filter(
342
+ (p) => !(p.source.service === source.service && p.source.key === source.key && p.target.service === target.service && p.target.key === target.key || p.source.service === target.service && p.source.key === target.key && p.target.service === source.service && p.target.key === source.key)
343
+ );
344
+ saveRegistry(registry);
345
+ }
326
346
  function findEntangled(source) {
327
347
  const registry = loadRegistry();
328
348
  return registry.pairs.filter(
@@ -373,6 +393,213 @@ function resolveScope(opts) {
373
393
  return [{ scope: "global", service: globalService() }];
374
394
  }
375
395
 
396
+ // src/core/hooks.ts
397
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
398
+ import { join as join4 } from "path";
399
+ import { homedir as homedir3 } from "os";
400
+ import { exec } from "child_process";
401
+ import { request as httpsRequest } from "https";
402
+ import { request as httpRequest } from "http";
403
+ import { randomUUID } from "crypto";
404
+ function getRegistryPath2() {
405
+ const dir = join4(homedir3(), ".config", "q-ring");
406
+ if (!existsSync4(dir)) {
407
+ mkdirSync3(dir, { recursive: true });
408
+ }
409
+ return join4(dir, "hooks.json");
410
+ }
411
+ function loadRegistry2() {
412
+ const path = getRegistryPath2();
413
+ if (!existsSync4(path)) {
414
+ return { hooks: [] };
415
+ }
416
+ try {
417
+ return JSON.parse(readFileSync4(path, "utf8"));
418
+ } catch {
419
+ return { hooks: [] };
420
+ }
421
+ }
422
+ function saveRegistry2(registry) {
423
+ writeFileSync2(getRegistryPath2(), JSON.stringify(registry, null, 2));
424
+ }
425
+ function registerHook(entry) {
426
+ const registry = loadRegistry2();
427
+ const hook = {
428
+ ...entry,
429
+ id: randomUUID().slice(0, 8),
430
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
431
+ };
432
+ registry.hooks.push(hook);
433
+ saveRegistry2(registry);
434
+ return hook;
435
+ }
436
+ function removeHook(id) {
437
+ const registry = loadRegistry2();
438
+ const before = registry.hooks.length;
439
+ registry.hooks = registry.hooks.filter((h) => h.id !== id);
440
+ if (registry.hooks.length < before) {
441
+ saveRegistry2(registry);
442
+ return true;
443
+ }
444
+ return false;
445
+ }
446
+ function listHooks() {
447
+ return loadRegistry2().hooks;
448
+ }
449
+ function matchesHook(hook, payload, tags) {
450
+ if (!hook.enabled) return false;
451
+ const m = hook.match;
452
+ if (m.action?.length && !m.action.includes(payload.action)) return false;
453
+ if (m.key && m.key !== payload.key) return false;
454
+ if (m.keyPattern) {
455
+ const regex = new RegExp(
456
+ "^" + m.keyPattern.replace(/\*/g, ".*") + "$",
457
+ "i"
458
+ );
459
+ if (!regex.test(payload.key)) return false;
460
+ }
461
+ if (m.tag && (!tags || !tags.includes(m.tag))) return false;
462
+ if (m.scope && m.scope !== payload.scope) return false;
463
+ return true;
464
+ }
465
+ function executeShell(command, payload) {
466
+ return new Promise((resolve) => {
467
+ const env = {
468
+ ...process.env,
469
+ QRING_HOOK_KEY: payload.key,
470
+ QRING_HOOK_ACTION: payload.action,
471
+ QRING_HOOK_SCOPE: payload.scope
472
+ };
473
+ exec(command, { timeout: 3e4, env }, (err, stdout, stderr) => {
474
+ if (err) {
475
+ resolve({ hookId: "", success: false, message: `Shell error: ${err.message}` });
476
+ } else {
477
+ resolve({ hookId: "", success: true, message: stdout.trim() || "OK" });
478
+ }
479
+ });
480
+ });
481
+ }
482
+ function executeHttp(url, payload) {
483
+ return new Promise((resolve) => {
484
+ const body = JSON.stringify(payload);
485
+ const parsedUrl = new URL(url);
486
+ const reqFn = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest;
487
+ const req = reqFn(
488
+ url,
489
+ {
490
+ method: "POST",
491
+ headers: {
492
+ "Content-Type": "application/json",
493
+ "Content-Length": Buffer.byteLength(body),
494
+ "User-Agent": "q-ring-hooks/1.0"
495
+ },
496
+ timeout: 1e4
497
+ },
498
+ (res) => {
499
+ let data = "";
500
+ res.on("data", (chunk) => data += chunk);
501
+ res.on("end", () => {
502
+ resolve({
503
+ hookId: "",
504
+ success: (res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300,
505
+ message: `HTTP ${res.statusCode}`
506
+ });
507
+ });
508
+ }
509
+ );
510
+ req.on("error", (err) => {
511
+ resolve({ hookId: "", success: false, message: `HTTP error: ${err.message}` });
512
+ });
513
+ req.on("timeout", () => {
514
+ req.destroy();
515
+ resolve({ hookId: "", success: false, message: "HTTP timeout" });
516
+ });
517
+ req.write(body);
518
+ req.end();
519
+ });
520
+ }
521
+ function executeSignal(target, signal = "SIGHUP") {
522
+ return new Promise((resolve) => {
523
+ const pid = parseInt(target, 10);
524
+ if (!isNaN(pid)) {
525
+ try {
526
+ process.kill(pid, signal);
527
+ resolve({ hookId: "", success: true, message: `Signal ${signal} sent to PID ${pid}` });
528
+ } catch (err) {
529
+ resolve({ hookId: "", success: false, message: `Signal error: ${err instanceof Error ? err.message : String(err)}` });
530
+ }
531
+ return;
532
+ }
533
+ exec(`pgrep -f "${target}"`, { timeout: 5e3 }, (err, stdout) => {
534
+ if (err || !stdout.trim()) {
535
+ resolve({ hookId: "", success: false, message: `Process "${target}" not found` });
536
+ return;
537
+ }
538
+ const pids = stdout.trim().split("\n").map((p) => parseInt(p.trim(), 10)).filter((p) => !isNaN(p));
539
+ let sent = 0;
540
+ for (const p of pids) {
541
+ try {
542
+ process.kill(p, signal);
543
+ sent++;
544
+ } catch {
545
+ }
546
+ }
547
+ resolve({ hookId: "", success: sent > 0, message: `Signal ${signal} sent to ${sent} process(es)` });
548
+ });
549
+ });
550
+ }
551
+ async function executeHook(hook, payload) {
552
+ let result;
553
+ switch (hook.type) {
554
+ case "shell":
555
+ result = hook.command ? await executeShell(hook.command, payload) : { hookId: hook.id, success: false, message: "No command specified" };
556
+ break;
557
+ case "http":
558
+ result = hook.url ? await executeHttp(hook.url, payload) : { hookId: hook.id, success: false, message: "No URL specified" };
559
+ break;
560
+ case "signal":
561
+ result = hook.signal ? await executeSignal(hook.signal.target, hook.signal.signal) : { hookId: hook.id, success: false, message: "No signal target specified" };
562
+ break;
563
+ default:
564
+ result = { hookId: hook.id, success: false, message: `Unknown hook type: ${hook.type}` };
565
+ }
566
+ result.hookId = hook.id;
567
+ return result;
568
+ }
569
+ async function fireHooks(payload, tags) {
570
+ const hooks = listHooks();
571
+ const matching = hooks.filter((h) => matchesHook(h, payload, tags));
572
+ if (matching.length === 0) return [];
573
+ const results = await Promise.allSettled(
574
+ matching.map((h) => executeHook(h, payload))
575
+ );
576
+ const hookResults = [];
577
+ for (const r of results) {
578
+ if (r.status === "fulfilled") {
579
+ hookResults.push(r.value);
580
+ } else {
581
+ hookResults.push({
582
+ hookId: "unknown",
583
+ success: false,
584
+ message: r.reason?.message ?? "Hook execution failed"
585
+ });
586
+ }
587
+ }
588
+ for (const r of hookResults) {
589
+ try {
590
+ logAudit({
591
+ action: "write",
592
+ key: payload.key,
593
+ scope: payload.scope,
594
+ source: payload.source,
595
+ detail: `hook:${r.hookId} ${r.success ? "ok" : "fail"} \u2014 ${r.message}`
596
+ });
597
+ } catch {
598
+ }
599
+ }
600
+ return hookResults;
601
+ }
602
+
376
603
  // src/core/keyring.ts
377
604
  function readEnvelope(service, key) {
378
605
  const entry = new Entry(service, key);
@@ -432,6 +659,9 @@ function setSecret(key, value, opts = {}) {
432
659
  const source = opts.source ?? "cli";
433
660
  const existing = readEnvelope(service, key);
434
661
  let envelope;
662
+ const rotFmt = opts.rotationFormat ?? existing?.meta.rotationFormat;
663
+ const rotPfx = opts.rotationPrefix ?? existing?.meta.rotationPrefix;
664
+ const prov = opts.provider ?? existing?.meta.provider;
435
665
  if (opts.states) {
436
666
  envelope = createEnvelope("", {
437
667
  states: opts.states,
@@ -440,7 +670,10 @@ function setSecret(key, value, opts = {}) {
440
670
  tags: opts.tags,
441
671
  ttlSeconds: opts.ttlSeconds,
442
672
  expiresAt: opts.expiresAt,
443
- entangled: existing?.meta.entangled
673
+ entangled: existing?.meta.entangled,
674
+ rotationFormat: rotFmt,
675
+ rotationPrefix: rotPfx,
676
+ provider: prov
444
677
  });
445
678
  } else {
446
679
  envelope = createEnvelope(value, {
@@ -448,7 +681,10 @@ function setSecret(key, value, opts = {}) {
448
681
  tags: opts.tags,
449
682
  ttlSeconds: opts.ttlSeconds,
450
683
  expiresAt: opts.expiresAt,
451
- entangled: existing?.meta.entangled
684
+ entangled: existing?.meta.entangled,
685
+ rotationFormat: rotFmt,
686
+ rotationPrefix: rotPfx,
687
+ provider: prov
452
688
  });
453
689
  }
454
690
  if (existing) {
@@ -480,6 +716,14 @@ function setSecret(key, value, opts = {}) {
480
716
  } catch {
481
717
  }
482
718
  }
719
+ fireHooks({
720
+ action: "write",
721
+ key,
722
+ scope,
723
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
724
+ source
725
+ }, envelope.meta.tags).catch(() => {
726
+ });
483
727
  }
484
728
  function deleteSecret(key, opts = {}) {
485
729
  const scopes = resolveScope(opts);
@@ -491,12 +735,31 @@ function deleteSecret(key, opts = {}) {
491
735
  if (entry.deleteCredential()) {
492
736
  deleted = true;
493
737
  logAudit({ action: "delete", key, scope, source });
738
+ fireHooks({
739
+ action: "delete",
740
+ key,
741
+ scope,
742
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
743
+ source
744
+ }).catch(() => {
745
+ });
494
746
  }
495
747
  } catch {
496
748
  }
497
749
  }
498
750
  return deleted;
499
751
  }
752
+ function hasSecret(key, opts = {}) {
753
+ const scopes = resolveScope(opts);
754
+ for (const { service } of scopes) {
755
+ const envelope = readEnvelope(service, key);
756
+ if (envelope) {
757
+ const decay = checkDecay(envelope);
758
+ if (!decay.isExpired) return true;
759
+ }
760
+ }
761
+ return false;
762
+ }
500
763
  function listSecrets(opts = {}) {
501
764
  const source = opts.source ?? "cli";
502
765
  const services = [];
@@ -538,8 +801,17 @@ function listSecrets(opts = {}) {
538
801
  function exportSecrets(opts = {}) {
539
802
  const format = opts.format ?? "env";
540
803
  const env = resolveEnv(opts);
541
- const entries = listSecrets(opts);
804
+ let entries = listSecrets(opts);
542
805
  const source = opts.source ?? "cli";
806
+ if (opts.keys?.length) {
807
+ const keySet = new Set(opts.keys);
808
+ entries = entries.filter((e) => keySet.has(e.key));
809
+ }
810
+ if (opts.tags?.length) {
811
+ entries = entries.filter(
812
+ (e) => opts.tags.some((t) => e.envelope?.meta.tags?.includes(t))
813
+ );
814
+ }
543
815
  const merged = /* @__PURE__ */ new Map();
544
816
  const globalEntries = entries.filter((e) => e.scope === "global");
545
817
  const projectEntries = entries.filter((e) => e.scope === "project");
@@ -581,6 +853,19 @@ function entangleSecrets(sourceKey, sourceOpts, targetKey, targetOpts) {
581
853
  detail: `entangled with ${targetKey}`
582
854
  });
583
855
  }
856
+ function disentangleSecrets(sourceKey, sourceOpts, targetKey, targetOpts) {
857
+ const sourceScopes = resolveScope({ ...sourceOpts, scope: sourceOpts.scope ?? "global" });
858
+ const targetScopes = resolveScope({ ...targetOpts, scope: targetOpts.scope ?? "global" });
859
+ const source = { service: sourceScopes[0].service, key: sourceKey };
860
+ const target = { service: targetScopes[0].service, key: targetKey };
861
+ disentangle(source, target);
862
+ logAudit({
863
+ action: "entangle",
864
+ key: sourceKey,
865
+ source: sourceOpts.source ?? "cli",
866
+ detail: `disentangled from ${targetKey}`
867
+ });
868
+ }
584
869
 
585
870
  // src/core/tunnel.ts
586
871
  var tunnelStore = /* @__PURE__ */ new Map();
@@ -655,21 +940,28 @@ function tunnelList() {
655
940
 
656
941
  export {
657
942
  checkDecay,
943
+ readProjectConfig,
658
944
  collapseEnvironment,
659
945
  logAudit,
660
946
  queryAudit,
661
947
  detectAnomalies,
662
948
  listEntanglements,
949
+ registerHook,
950
+ removeHook,
951
+ listHooks,
952
+ fireHooks,
663
953
  getSecret,
664
954
  getEnvelope,
665
955
  setSecret,
666
956
  deleteSecret,
957
+ hasSecret,
667
958
  listSecrets,
668
959
  exportSecrets,
669
960
  entangleSecrets,
961
+ disentangleSecrets,
670
962
  tunnelCreate,
671
963
  tunnelRead,
672
964
  tunnelDestroy,
673
965
  tunnelList
674
966
  };
675
- //# sourceMappingURL=chunk-F4SPZ774.js.map
967
+ //# sourceMappingURL=chunk-6IQ5SFLI.js.map