@i4ctime/q-ring 0.4.0 → 0.9.2

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.
@@ -23,7 +23,9 @@ function createEnvelope(value, opts) {
23
23
  accessCount: 0,
24
24
  rotationFormat: opts?.rotationFormat,
25
25
  rotationPrefix: opts?.rotationPrefix,
26
- provider: opts?.provider
26
+ provider: opts?.provider,
27
+ requiresApproval: opts?.requiresApproval,
28
+ jitProvider: opts?.jitProvider
27
29
  }
28
30
  };
29
31
  }
@@ -204,9 +206,10 @@ function mapEnvName(raw) {
204
206
  }
205
207
 
206
208
  // src/core/observer.ts
207
- import { existsSync as existsSync2, mkdirSync, appendFileSync, readFileSync as readFileSync2 } from "fs";
209
+ import { existsSync as existsSync2, mkdirSync, appendFileSync, readFileSync as readFileSync2, openSync, fstatSync, readSync, closeSync } from "fs";
208
210
  import { join as join2 } from "path";
209
211
  import { homedir } from "os";
212
+ import { createHash } from "crypto";
210
213
  function getAuditDir() {
211
214
  const dir = join2(homedir(), ".config", "q-ring");
212
215
  if (!existsSync2(dir)) {
@@ -217,11 +220,36 @@ function getAuditDir() {
217
220
  function getAuditPath() {
218
221
  return join2(getAuditDir(), "audit.jsonl");
219
222
  }
223
+ function getLastLineHash() {
224
+ const path = getAuditPath();
225
+ if (!existsSync2(path)) return void 0;
226
+ try {
227
+ const fd = openSync(path, "r");
228
+ const stat = fstatSync(fd);
229
+ if (stat.size === 0) {
230
+ closeSync(fd);
231
+ return void 0;
232
+ }
233
+ const tailSize = Math.min(stat.size, 8192);
234
+ const buf = Buffer.alloc(tailSize);
235
+ readSync(fd, buf, 0, tailSize, stat.size - tailSize);
236
+ closeSync(fd);
237
+ const tail = buf.toString("utf8");
238
+ const lines = tail.split("\n").filter((l) => l.trim());
239
+ if (lines.length === 0) return void 0;
240
+ const lastLine = lines[lines.length - 1];
241
+ return createHash("sha256").update(lastLine).digest("hex");
242
+ } catch {
243
+ return void 0;
244
+ }
245
+ }
220
246
  function logAudit(event) {
247
+ const prevHash = getLastLineHash();
221
248
  const full = {
222
249
  ...event,
223
250
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
224
- pid: process.pid
251
+ pid: process.pid,
252
+ prevHash
225
253
  };
226
254
  try {
227
255
  appendFileSync(getAuditPath(), JSON.stringify(full) + "\n");
@@ -240,35 +268,99 @@ function queryAudit(query = {}) {
240
268
  return null;
241
269
  }
242
270
  }).filter((e) => e !== null);
243
- if (query.key) {
244
- events = events.filter((e) => e.key === query.key);
245
- }
246
- if (query.action) {
247
- events = events.filter((e) => e.action === query.action);
248
- }
271
+ if (query.key) events = events.filter((e) => e.key === query.key);
272
+ if (query.action) events = events.filter((e) => e.action === query.action);
273
+ if (query.source) events = events.filter((e) => e.source === query.source);
274
+ if (query.correlationId) events = events.filter((e) => e.correlationId === query.correlationId);
249
275
  if (query.since) {
250
276
  const since = new Date(query.since).getTime();
251
- events = events.filter(
252
- (e) => new Date(e.timestamp).getTime() >= since
253
- );
277
+ events = events.filter((e) => new Date(e.timestamp).getTime() >= since);
254
278
  }
255
279
  events.sort(
256
280
  (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
257
281
  );
258
- if (query.limit) {
259
- events = events.slice(0, query.limit);
260
- }
282
+ if (query.limit) events = events.slice(0, query.limit);
261
283
  return events;
262
284
  } catch {
263
285
  return [];
264
286
  }
265
287
  }
288
+ function verifyAuditChain() {
289
+ const path = getAuditPath();
290
+ if (!existsSync2(path)) {
291
+ return { totalEvents: 0, validEvents: 0, intact: true };
292
+ }
293
+ const lines = readFileSync2(path, "utf8").split("\n").filter((l) => l.trim());
294
+ if (lines.length === 0) {
295
+ return { totalEvents: 0, validEvents: 0, intact: true };
296
+ }
297
+ let validEvents = 0;
298
+ for (let i = 0; i < lines.length; i++) {
299
+ let event;
300
+ try {
301
+ event = JSON.parse(lines[i]);
302
+ } catch {
303
+ return {
304
+ totalEvents: lines.length,
305
+ validEvents,
306
+ brokenAt: i,
307
+ intact: false
308
+ };
309
+ }
310
+ if (i === 0) {
311
+ validEvents++;
312
+ continue;
313
+ }
314
+ const expectedHash = createHash("sha256").update(lines[i - 1]).digest("hex");
315
+ if (event.prevHash !== expectedHash) {
316
+ return {
317
+ totalEvents: lines.length,
318
+ validEvents,
319
+ brokenAt: i,
320
+ brokenEvent: event,
321
+ intact: false
322
+ };
323
+ }
324
+ validEvents++;
325
+ }
326
+ return { totalEvents: lines.length, validEvents, intact: true };
327
+ }
328
+ function exportAudit(opts = {}) {
329
+ const path = getAuditPath();
330
+ if (!existsSync2(path)) return opts.format === "json" ? "[]" : "";
331
+ const lines = readFileSync2(path, "utf8").split("\n").filter((l) => l.trim());
332
+ let events = lines.map((l) => {
333
+ try {
334
+ return JSON.parse(l);
335
+ } catch {
336
+ return null;
337
+ }
338
+ }).filter((e) => e !== null);
339
+ if (opts.since) {
340
+ const since = new Date(opts.since).getTime();
341
+ events = events.filter((e) => new Date(e.timestamp).getTime() >= since);
342
+ }
343
+ if (opts.until) {
344
+ const until = new Date(opts.until).getTime();
345
+ events = events.filter((e) => new Date(e.timestamp).getTime() <= until);
346
+ }
347
+ if (opts.format === "json") {
348
+ return JSON.stringify(events, null, 2);
349
+ }
350
+ if (opts.format === "csv") {
351
+ const header = "timestamp,action,key,scope,env,source,pid,correlationId,detail";
352
+ const rows = events.map(
353
+ (e) => `${e.timestamp},${e.action},${e.key ?? ""},${e.scope ?? ""},${e.env ?? ""},${e.source},${e.pid},${e.correlationId ?? ""},${(e.detail ?? "").replace(/,/g, ";")}`
354
+ );
355
+ return [header, ...rows].join("\n");
356
+ }
357
+ return events.map((e) => JSON.stringify(e)).join("\n");
358
+ }
266
359
  function detectAnomalies(key) {
267
360
  const recent = queryAudit({
268
361
  key,
269
362
  action: "read",
270
363
  since: new Date(Date.now() - 36e5).toISOString()
271
- // last hour
272
364
  });
273
365
  const anomalies = [];
274
366
  if (key && recent.length > 50) {
@@ -289,11 +381,19 @@ function detectAnomalies(key) {
289
381
  events: nightAccess
290
382
  });
291
383
  }
384
+ const verification = verifyAuditChain();
385
+ if (!verification.intact) {
386
+ anomalies.push({
387
+ type: "tampered",
388
+ description: `Audit chain broken at event #${verification.brokenAt}`,
389
+ events: verification.brokenEvent ? [verification.brokenEvent] : []
390
+ });
391
+ }
292
392
  return anomalies;
293
393
  }
294
394
 
295
395
  // src/core/entanglement.ts
296
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
396
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
297
397
  import { join as join3 } from "path";
298
398
  import { homedir as homedir2 } from "os";
299
399
  function getRegistryPath() {
@@ -314,38 +414,38 @@ function loadRegistry() {
314
414
  return { pairs: [] };
315
415
  }
316
416
  }
317
- function saveRegistry(registry) {
318
- writeFileSync(getRegistryPath(), JSON.stringify(registry, null, 2));
417
+ function saveRegistry(registry2) {
418
+ writeFileSync2(getRegistryPath(), JSON.stringify(registry2, null, 2));
319
419
  }
320
420
  function entangle(source, target) {
321
- const registry = loadRegistry();
322
- const exists = registry.pairs.some(
421
+ const registry2 = loadRegistry();
422
+ const exists = registry2.pairs.some(
323
423
  (p) => p.source.service === source.service && p.source.key === source.key && p.target.service === target.service && p.target.key === target.key
324
424
  );
325
425
  if (!exists) {
326
- registry.pairs.push({
426
+ registry2.pairs.push({
327
427
  source,
328
428
  target,
329
429
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
330
430
  });
331
- registry.pairs.push({
431
+ registry2.pairs.push({
332
432
  source: target,
333
433
  target: source,
334
434
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
335
435
  });
336
- saveRegistry(registry);
436
+ saveRegistry(registry2);
337
437
  }
338
438
  }
339
439
  function disentangle(source, target) {
340
- const registry = loadRegistry();
341
- registry.pairs = registry.pairs.filter(
440
+ const registry2 = loadRegistry();
441
+ registry2.pairs = registry2.pairs.filter(
342
442
  (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
443
  );
344
- saveRegistry(registry);
444
+ saveRegistry(registry2);
345
445
  }
346
446
  function findEntangled(source) {
347
- const registry = loadRegistry();
348
- return registry.pairs.filter(
447
+ const registry2 = loadRegistry();
448
+ return registry2.pairs.filter(
349
449
  (p) => p.source.service === source.service && p.source.key === source.key
350
450
  ).map((p) => p.target);
351
451
  }
@@ -357,9 +457,9 @@ function listEntanglements() {
357
457
  import { Entry, findCredentials } from "@napi-rs/keyring";
358
458
 
359
459
  // src/utils/hash.ts
360
- import { createHash } from "crypto";
460
+ import { createHash as createHash2 } from "crypto";
361
461
  function hashProjectPath(projectPath) {
362
- return createHash("sha256").update(projectPath).digest("hex").slice(0, 12);
462
+ return createHash2("sha256").update(projectPath).digest("hex").slice(0, 12);
363
463
  }
364
464
 
365
465
  // src/core/scope.ts
@@ -371,36 +471,133 @@ function projectService(projectPath) {
371
471
  const hash = hashProjectPath(projectPath);
372
472
  return `${SERVICE_PREFIX}:project:${hash}`;
373
473
  }
474
+ function teamService(teamId) {
475
+ return `${SERVICE_PREFIX}:team:${teamId}`;
476
+ }
477
+ function orgService(orgId) {
478
+ return `${SERVICE_PREFIX}:org:${orgId}`;
479
+ }
374
480
  function resolveScope(opts) {
375
- const { scope, projectPath } = opts;
481
+ const { scope, projectPath, teamId, orgId } = opts;
376
482
  if (scope === "global") {
377
483
  return [{ scope: "global", service: globalService() }];
378
484
  }
379
485
  if (scope === "project") {
380
- if (!projectPath) {
381
- throw new Error("Project path is required for project scope");
382
- }
383
- return [
384
- { scope: "project", service: projectService(projectPath), projectPath }
385
- ];
486
+ if (!projectPath) throw new Error("Project path is required for project scope");
487
+ return [{ scope: "project", service: projectService(projectPath), projectPath }];
488
+ }
489
+ if (scope === "team") {
490
+ if (!teamId) throw new Error("Team ID is required for team scope");
491
+ return [{ scope: "team", service: teamService(teamId), teamId }];
386
492
  }
493
+ if (scope === "org") {
494
+ if (!orgId) throw new Error("Org ID is required for org scope");
495
+ return [{ scope: "org", service: orgService(orgId), orgId }];
496
+ }
497
+ const chain = [];
387
498
  if (projectPath) {
388
- return [
389
- { scope: "project", service: projectService(projectPath), projectPath },
390
- { scope: "global", service: globalService() }
391
- ];
499
+ chain.push({ scope: "project", service: projectService(projectPath), projectPath });
500
+ }
501
+ if (teamId) {
502
+ chain.push({ scope: "team", service: teamService(teamId), teamId });
503
+ }
504
+ if (orgId) {
505
+ chain.push({ scope: "org", service: orgService(orgId), orgId });
392
506
  }
393
- return [{ scope: "global", service: globalService() }];
507
+ chain.push({ scope: "global", service: globalService() });
508
+ return chain;
394
509
  }
395
510
 
396
511
  // src/core/hooks.ts
397
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
512
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
398
513
  import { join as join4 } from "path";
399
514
  import { homedir as homedir3 } from "os";
400
515
  import { exec } from "child_process";
516
+ import { randomUUID } from "crypto";
517
+ import { lookup } from "dns/promises";
518
+
519
+ // src/utils/http-request.ts
401
520
  import { request as httpsRequest } from "https";
402
521
  import { request as httpRequest } from "http";
403
- import { randomUUID } from "crypto";
522
+ var DEFAULT_TIMEOUT_MS = 1e4;
523
+ var DEFAULT_MAX_RESPONSE_BYTES = 65536;
524
+ function httpRequest_(opts) {
525
+ const {
526
+ url,
527
+ method = "GET",
528
+ headers = {},
529
+ body,
530
+ timeoutMs = DEFAULT_TIMEOUT_MS,
531
+ maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES
532
+ } = opts;
533
+ return new Promise((resolve, reject) => {
534
+ const parsed = new URL(url);
535
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
536
+ reject(new Error(`Unsupported URL protocol: ${parsed.protocol}`));
537
+ return;
538
+ }
539
+ const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequest;
540
+ const reqHeaders = { ...headers };
541
+ if (body && !reqHeaders["Content-Length"]) {
542
+ reqHeaders["Content-Length"] = Buffer.byteLength(body);
543
+ }
544
+ const req = reqFn(
545
+ url,
546
+ { method, headers: reqHeaders, timeout: timeoutMs },
547
+ (res) => {
548
+ const chunks = [];
549
+ let totalBytes = 0;
550
+ let truncated = false;
551
+ res.on("data", (chunk) => {
552
+ totalBytes += chunk.length;
553
+ if (totalBytes > maxResponseBytes) {
554
+ truncated = true;
555
+ res.destroy();
556
+ return;
557
+ }
558
+ chunks.push(chunk);
559
+ });
560
+ let settled = false;
561
+ const settle = (result) => {
562
+ if (!settled) {
563
+ settled = true;
564
+ resolve(result);
565
+ }
566
+ };
567
+ const fail = (err) => {
568
+ if (!settled) {
569
+ settled = true;
570
+ reject(err);
571
+ }
572
+ };
573
+ res.on("error", (err) => fail(new Error(`Response error: ${err.message}`)));
574
+ res.on("end", () => {
575
+ settle({
576
+ statusCode: res.statusCode ?? 0,
577
+ body: Buffer.concat(chunks).toString("utf8"),
578
+ truncated
579
+ });
580
+ });
581
+ res.on("close", () => {
582
+ settle({
583
+ statusCode: res.statusCode ?? 0,
584
+ body: Buffer.concat(chunks).toString("utf8"),
585
+ truncated
586
+ });
587
+ });
588
+ }
589
+ );
590
+ req.on("error", (err) => reject(new Error(`Network error: ${err.message}`)));
591
+ req.on("timeout", () => {
592
+ req.destroy();
593
+ reject(new Error("Request timed out"));
594
+ });
595
+ if (body) req.write(body);
596
+ req.end();
597
+ });
598
+ }
599
+
600
+ // src/core/hooks.ts
404
601
  function getRegistryPath2() {
405
602
  const dir = join4(homedir3(), ".config", "q-ring");
406
603
  if (!existsSync4(dir)) {
@@ -419,26 +616,26 @@ function loadRegistry2() {
419
616
  return { hooks: [] };
420
617
  }
421
618
  }
422
- function saveRegistry2(registry) {
423
- writeFileSync2(getRegistryPath2(), JSON.stringify(registry, null, 2));
619
+ function saveRegistry2(registry2) {
620
+ writeFileSync3(getRegistryPath2(), JSON.stringify(registry2, null, 2));
424
621
  }
425
622
  function registerHook(entry) {
426
- const registry = loadRegistry2();
623
+ const registry2 = loadRegistry2();
427
624
  const hook = {
428
625
  ...entry,
429
626
  id: randomUUID().slice(0, 8),
430
627
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
431
628
  };
432
- registry.hooks.push(hook);
433
- saveRegistry2(registry);
629
+ registry2.hooks.push(hook);
630
+ saveRegistry2(registry2);
434
631
  return hook;
435
632
  }
436
633
  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);
634
+ const registry2 = loadRegistry2();
635
+ const before = registry2.hooks.length;
636
+ registry2.hooks = registry2.hooks.filter((h) => h.id !== id);
637
+ if (registry2.hooks.length < before) {
638
+ saveRegistry2(registry2);
442
639
  return true;
443
640
  }
444
641
  return false;
@@ -446,22 +643,6 @@ function removeHook(id) {
446
643
  function listHooks() {
447
644
  return loadRegistry2().hooks;
448
645
  }
449
- function enableHook(id) {
450
- const registry = loadRegistry2();
451
- const hook = registry.hooks.find((h) => h.id === id);
452
- if (!hook) return false;
453
- hook.enabled = true;
454
- saveRegistry2(registry);
455
- return true;
456
- }
457
- function disableHook(id) {
458
- const registry = loadRegistry2();
459
- const hook = registry.hooks.find((h) => h.id === id);
460
- if (!hook) return false;
461
- hook.enabled = false;
462
- saveRegistry2(registry);
463
- return true;
464
- }
465
646
  function matchesHook(hook, payload, tags) {
466
647
  if (!hook.enabled) return false;
467
648
  const m = hook.match;
@@ -495,44 +676,76 @@ function executeShell(command, payload) {
495
676
  });
496
677
  });
497
678
  }
498
- function executeHttp(url, payload) {
499
- return new Promise((resolve) => {
679
+ function isPrivateIP(ip) {
680
+ const octet = "(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})";
681
+ const ipv4Re = new RegExp(`^::ffff:(${octet}\\.${octet}\\.${octet}\\.${octet})$`, "i");
682
+ const ipv4Mapped = ip.match(ipv4Re);
683
+ if (ipv4Mapped) return isPrivateIP(ipv4Mapped[1]);
684
+ if (/^127\./.test(ip)) return true;
685
+ if (/^10\./.test(ip)) return true;
686
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
687
+ if (/^192\.168\./.test(ip)) return true;
688
+ if (/^169\.254\./.test(ip)) return true;
689
+ if (ip === "0.0.0.0") return true;
690
+ if (ip === "::1" || ip === "::") return true;
691
+ if (/^f[cd][0-9a-f]{2}:/i.test(ip)) return true;
692
+ if (/^fe80:/i.test(ip)) return true;
693
+ return false;
694
+ }
695
+ async function checkSSRF(url) {
696
+ if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
697
+ try {
698
+ const parsed = new URL(url);
699
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
700
+ if (isPrivateIP(hostname)) {
701
+ return `Blocked: hook URL resolves to private address (${hostname}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
702
+ }
703
+ const results = await lookup(hostname, { all: true });
704
+ for (const { address } of results) {
705
+ if (isPrivateIP(address)) {
706
+ return `Blocked: hook URL "${hostname}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
707
+ }
708
+ }
709
+ } catch {
710
+ }
711
+ return null;
712
+ }
713
+ async function executeHttp(url, payload) {
714
+ const ssrfBlock = await checkSSRF(url);
715
+ if (ssrfBlock) {
716
+ logAudit({
717
+ action: "policy_deny",
718
+ key: payload.key,
719
+ scope: payload.scope,
720
+ source: payload.source,
721
+ detail: `hook SSRF blocked: ${url}`
722
+ });
723
+ return { hookId: "", success: false, message: ssrfBlock };
724
+ }
725
+ try {
500
726
  const body = JSON.stringify(payload);
501
- const parsedUrl = new URL(url);
502
- const reqFn = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest;
503
- const req = reqFn(
727
+ const res = await httpRequest_({
504
728
  url,
505
- {
506
- method: "POST",
507
- headers: {
508
- "Content-Type": "application/json",
509
- "Content-Length": Buffer.byteLength(body),
510
- "User-Agent": "q-ring-hooks/1.0"
511
- },
512
- timeout: 1e4
729
+ method: "POST",
730
+ headers: {
731
+ "Content-Type": "application/json",
732
+ "User-Agent": "q-ring-hooks/1.0"
513
733
  },
514
- (res) => {
515
- let data = "";
516
- res.on("data", (chunk) => data += chunk);
517
- res.on("end", () => {
518
- resolve({
519
- hookId: "",
520
- success: (res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300,
521
- message: `HTTP ${res.statusCode}`
522
- });
523
- });
524
- }
525
- );
526
- req.on("error", (err) => {
527
- resolve({ hookId: "", success: false, message: `HTTP error: ${err.message}` });
528
- });
529
- req.on("timeout", () => {
530
- req.destroy();
531
- resolve({ hookId: "", success: false, message: "HTTP timeout" });
734
+ body,
735
+ timeoutMs: 1e4
532
736
  });
533
- req.write(body);
534
- req.end();
535
- });
737
+ return {
738
+ hookId: "",
739
+ success: res.statusCode >= 200 && res.statusCode < 300,
740
+ message: `HTTP ${res.statusCode}`
741
+ };
742
+ } catch (err) {
743
+ return {
744
+ hookId: "",
745
+ success: false,
746
+ message: err instanceof Error ? err.message : "HTTP error"
747
+ };
748
+ }
536
749
  }
537
750
  function executeSignal(target, signal = "SIGHUP") {
538
751
  return new Promise((resolve) => {
@@ -616,6 +829,279 @@ async function fireHooks(payload, tags) {
616
829
  return hookResults;
617
830
  }
618
831
 
832
+ // src/core/approval.ts
833
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
834
+ import { join as join5 } from "path";
835
+ import { homedir as homedir4 } from "os";
836
+ import { createHmac, randomBytes } from "crypto";
837
+ function getHmacSecret() {
838
+ const secretPath = join5(homedir4(), ".config", "q-ring", ".approval-key");
839
+ const dir = join5(homedir4(), ".config", "q-ring");
840
+ if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true });
841
+ if (existsSync5(secretPath)) {
842
+ return readFileSync5(secretPath, "utf8").trim();
843
+ }
844
+ const secret = randomBytes(32).toString("hex");
845
+ writeFileSync4(secretPath, secret, { mode: 384 });
846
+ return secret;
847
+ }
848
+ function computeHmac(entry) {
849
+ const payload = `${entry.id}|${entry.key}|${entry.scope}|${entry.reason}|${entry.grantedBy}|${entry.grantedAt}|${entry.expiresAt}`;
850
+ return createHmac("sha256", getHmacSecret()).update(payload).digest("hex");
851
+ }
852
+ function verifyHmac(entry) {
853
+ const expected = computeHmac(entry);
854
+ return expected === entry.hmac;
855
+ }
856
+ function getRegistryPath3() {
857
+ const dir = join5(homedir4(), ".config", "q-ring");
858
+ if (!existsSync5(dir)) {
859
+ mkdirSync4(dir, { recursive: true });
860
+ }
861
+ return join5(dir, "approvals.json");
862
+ }
863
+ function loadRegistry3() {
864
+ const path = getRegistryPath3();
865
+ if (!existsSync5(path)) {
866
+ return { approvals: [] };
867
+ }
868
+ try {
869
+ return JSON.parse(readFileSync5(path, "utf8"));
870
+ } catch {
871
+ return { approvals: [] };
872
+ }
873
+ }
874
+ function hasApproval(key, scope) {
875
+ const registry2 = loadRegistry3();
876
+ const entry = registry2.approvals.find(
877
+ (a) => a.key === key && a.scope === scope
878
+ );
879
+ if (!entry) return false;
880
+ if (new Date(entry.expiresAt).getTime() < Date.now()) return false;
881
+ if (!verifyHmac(entry)) return false;
882
+ return true;
883
+ }
884
+
885
+ // src/core/provision.ts
886
+ import { execFileSync, spawnSync } from "child_process";
887
+ var ProvisionRegistry = class {
888
+ providers = /* @__PURE__ */ new Map();
889
+ register(provider) {
890
+ this.providers.set(provider.name, provider);
891
+ }
892
+ get(name) {
893
+ return this.providers.get(name);
894
+ }
895
+ listProviders() {
896
+ return [...this.providers.values()];
897
+ }
898
+ };
899
+ var awsStsProvider = {
900
+ name: "aws-sts",
901
+ description: "AWS STS AssumeRole (requires existing local AWS CLI credentials)",
902
+ provision(configRaw) {
903
+ let config;
904
+ try {
905
+ config = JSON.parse(configRaw);
906
+ } catch {
907
+ throw new Error('aws-sts requires valid JSON config (e.g. {"roleArn":"arn:aws:..."})');
908
+ }
909
+ const roleArn = config.roleArn;
910
+ const sessionName = config.sessionName || "q-ring-agent";
911
+ const duration = config.durationSeconds || 3600;
912
+ if (!roleArn) throw new Error("aws-sts requires roleArn in config");
913
+ try {
914
+ const output = execFileSync("aws", [
915
+ "sts",
916
+ "assume-role",
917
+ "--role-arn",
918
+ roleArn,
919
+ "--role-session-name",
920
+ sessionName,
921
+ "--duration-seconds",
922
+ String(duration),
923
+ "--output",
924
+ "json"
925
+ ], { encoding: "utf8" });
926
+ const parsed = JSON.parse(output);
927
+ const creds = parsed.Credentials;
928
+ const value = JSON.stringify({
929
+ AWS_ACCESS_KEY_ID: creds.AccessKeyId,
930
+ AWS_SECRET_ACCESS_KEY: creds.SecretAccessKey,
931
+ AWS_SESSION_TOKEN: creds.SessionToken
932
+ });
933
+ return {
934
+ value,
935
+ expiresAt: creds.Expiration
936
+ };
937
+ } catch (err) {
938
+ throw new Error(`AWS STS provision failed: ${err instanceof Error ? err.message : String(err)}`);
939
+ }
940
+ }
941
+ };
942
+ var httpProvider = {
943
+ name: "http",
944
+ description: "Generic HTTP token endpoint using Node.js http/https",
945
+ provision(configRaw) {
946
+ let config;
947
+ try {
948
+ config = JSON.parse(configRaw);
949
+ } catch {
950
+ throw new Error("http provider requires valid JSON config");
951
+ }
952
+ const url = config.url;
953
+ const method = config.method || "POST";
954
+ const valuePath = config.valuePath || "token";
955
+ const expiresInSeconds = config.expiresInSeconds || 3600;
956
+ if (!url) throw new Error("http provider requires url in config");
957
+ const headers = {
958
+ "User-Agent": "q-ring-jit/1.0",
959
+ ...config.headers ?? {}
960
+ };
961
+ let bodyStr;
962
+ if (config.body) {
963
+ headers["Content-Type"] = "application/json";
964
+ bodyStr = JSON.stringify(config.body);
965
+ }
966
+ const scriptConfig = JSON.stringify({ url, method, headers, bodyStr });
967
+ const script = `
968
+ const cfg = JSON.parse(process.env.__QRING_HTTP_CFG);
969
+ const parsedUrl = new URL(cfg.url);
970
+ const http = require(parsedUrl.protocol === "https:" ? "node:https" : "node:http");
971
+ const req = http.request(cfg.url, { method: cfg.method, headers: cfg.headers, timeout: 30000 }, (res) => {
972
+ let body = "";
973
+ res.on("data", (chunk) => body += chunk);
974
+ res.on("end", () => process.stdout.write(body));
975
+ });
976
+ req.on("error", (e) => { process.stderr.write(e.message); process.exit(1); });
977
+ if (cfg.bodyStr) req.write(cfg.bodyStr);
978
+ req.end();
979
+ `;
980
+ try {
981
+ const result = spawnSync("node", ["-e", script], {
982
+ encoding: "utf8",
983
+ timeout: 35e3,
984
+ env: { ...process.env, __QRING_HTTP_CFG: scriptConfig }
985
+ });
986
+ if (result.status !== 0) {
987
+ throw new Error(result.stderr || "HTTP request failed");
988
+ }
989
+ const parsed = JSON.parse(result.stdout);
990
+ let val = parsed;
991
+ for (const key of valuePath.split(".")) {
992
+ val = val[key];
993
+ }
994
+ return {
995
+ value: String(val),
996
+ expiresAt: new Date(Date.now() + expiresInSeconds * 1e3).toISOString()
997
+ };
998
+ } catch (err) {
999
+ throw new Error(`HTTP provision failed: ${err instanceof Error ? err.message : String(err)}`);
1000
+ }
1001
+ }
1002
+ };
1003
+ var registry = new ProvisionRegistry();
1004
+ registry.register(awsStsProvider);
1005
+ registry.register(httpProvider);
1006
+
1007
+ // src/core/policy.ts
1008
+ var cachedPolicy = null;
1009
+ function loadPolicy(projectPath) {
1010
+ const pp = projectPath ?? process.cwd();
1011
+ if (cachedPolicy && cachedPolicy.path === pp) {
1012
+ return cachedPolicy.policy;
1013
+ }
1014
+ const config = readProjectConfig(pp);
1015
+ const policy = config?.policy ?? {};
1016
+ cachedPolicy = { path: pp, policy };
1017
+ return policy;
1018
+ }
1019
+ function checkToolPolicy(toolName, projectPath) {
1020
+ const policy = loadPolicy(projectPath);
1021
+ if (!policy.mcp) return { allowed: true, policySource: "no-policy" };
1022
+ if (policy.mcp.denyTools?.includes(toolName)) {
1023
+ return {
1024
+ allowed: false,
1025
+ reason: `Tool "${toolName}" is denied by project policy`,
1026
+ policySource: ".q-ring.json policy.mcp.denyTools"
1027
+ };
1028
+ }
1029
+ if (policy.mcp.allowTools && !policy.mcp.allowTools.includes(toolName)) {
1030
+ return {
1031
+ allowed: false,
1032
+ reason: `Tool "${toolName}" is not in the allowlist`,
1033
+ policySource: ".q-ring.json policy.mcp.allowTools"
1034
+ };
1035
+ }
1036
+ return { allowed: true, policySource: ".q-ring.json" };
1037
+ }
1038
+ function checkKeyReadPolicy(key, tags, projectPath) {
1039
+ const policy = loadPolicy(projectPath);
1040
+ if (!policy.mcp) return { allowed: true, policySource: "no-policy" };
1041
+ if (policy.mcp.deniedKeys?.includes(key)) {
1042
+ return {
1043
+ allowed: false,
1044
+ reason: `Key "${key}" is denied by project policy`,
1045
+ policySource: ".q-ring.json policy.mcp.deniedKeys"
1046
+ };
1047
+ }
1048
+ if (policy.mcp.readableKeys && !policy.mcp.readableKeys.includes(key)) {
1049
+ return {
1050
+ allowed: false,
1051
+ reason: `Key "${key}" is not in the readable keys allowlist`,
1052
+ policySource: ".q-ring.json policy.mcp.readableKeys"
1053
+ };
1054
+ }
1055
+ if (tags && policy.mcp.deniedTags) {
1056
+ const blocked = tags.find((t) => policy.mcp.deniedTags.includes(t));
1057
+ if (blocked) {
1058
+ return {
1059
+ allowed: false,
1060
+ reason: `Tag "${blocked}" is denied by project policy`,
1061
+ policySource: ".q-ring.json policy.mcp.deniedTags"
1062
+ };
1063
+ }
1064
+ }
1065
+ return { allowed: true, policySource: ".q-ring.json" };
1066
+ }
1067
+ function checkExecPolicy(command, projectPath) {
1068
+ const policy = loadPolicy(projectPath);
1069
+ if (!policy.exec) return { allowed: true, policySource: "no-policy" };
1070
+ if (policy.exec.denyCommands) {
1071
+ const denied = policy.exec.denyCommands.find((d) => command.includes(d));
1072
+ if (denied) {
1073
+ return {
1074
+ allowed: false,
1075
+ reason: `Command containing "${denied}" is denied by project policy`,
1076
+ policySource: ".q-ring.json policy.exec.denyCommands"
1077
+ };
1078
+ }
1079
+ }
1080
+ if (policy.exec.allowCommands) {
1081
+ const allowed = policy.exec.allowCommands.some((a) => command.startsWith(a));
1082
+ if (!allowed) {
1083
+ return {
1084
+ allowed: false,
1085
+ reason: `Command "${command}" is not in the exec allowlist`,
1086
+ policySource: ".q-ring.json policy.exec.allowCommands"
1087
+ };
1088
+ }
1089
+ }
1090
+ return { allowed: true, policySource: ".q-ring.json" };
1091
+ }
1092
+ function getExecMaxRuntime(projectPath) {
1093
+ return loadPolicy(projectPath).exec?.maxRuntimeSeconds;
1094
+ }
1095
+ function getPolicySummary(projectPath) {
1096
+ const policy = loadPolicy(projectPath);
1097
+ return {
1098
+ hasMcpPolicy: !!policy.mcp,
1099
+ hasExecPolicy: !!policy.exec,
1100
+ hasSecretPolicy: !!policy.secrets,
1101
+ details: policy
1102
+ };
1103
+ }
1104
+
619
1105
  // src/core/keyring.ts
620
1106
  function readEnvelope(service, key) {
621
1107
  const entry = new Entry(service, key);
@@ -633,29 +1119,104 @@ function resolveEnv(opts) {
633
1119
  const result = collapseEnvironment({ projectPath: opts.projectPath });
634
1120
  return result?.env;
635
1121
  }
1122
+ function resolveTemplates(value, opts, seen) {
1123
+ if (!value.includes("{{") || !value.includes("}}")) return value;
1124
+ return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1125
+ const refKey = refKeyRaw.trim();
1126
+ const refValue = getSecret(refKey, { ...opts, _seen: seen });
1127
+ if (refValue === null) {
1128
+ throw new Error(`Template resolution failed: referenced secret "${refKey}" not found`);
1129
+ }
1130
+ return refValue;
1131
+ });
1132
+ }
1133
+ function resolveTemplatesOffline(value, rawValues, seen) {
1134
+ if (!value.includes("{{") || !value.includes("}}")) return value;
1135
+ return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1136
+ const refKey = refKeyRaw.trim();
1137
+ if (seen.has(refKey)) {
1138
+ throw new Error(`Circular dependency detected: ${[...seen].join(" -> ")} -> ${refKey}`);
1139
+ }
1140
+ const rawRef = rawValues.get(refKey);
1141
+ if (rawRef === void 0) {
1142
+ throw new Error(`Template resolution failed: referenced secret "${refKey}" not found`);
1143
+ }
1144
+ const nextSeen = new Set(seen);
1145
+ nextSeen.add(refKey);
1146
+ return resolveTemplatesOffline(rawRef, rawValues, nextSeen);
1147
+ });
1148
+ }
636
1149
  function getSecret(key, opts = {}) {
637
1150
  const scopes = resolveScope(opts);
638
1151
  const env = resolveEnv(opts);
639
1152
  const source = opts.source ?? "cli";
1153
+ const seen = opts._seen ?? /* @__PURE__ */ new Set();
1154
+ if (source === "mcp") {
1155
+ const policyDecision = checkKeyReadPolicy(key, void 0, opts.projectPath);
1156
+ if (!policyDecision.allowed) {
1157
+ throw new Error(`Policy Denied: ${policyDecision.reason}`);
1158
+ }
1159
+ }
1160
+ if (seen.has(key)) {
1161
+ throw new Error(`Circular dependency detected: ${[...seen].join(" -> ")} -> ${key}`);
1162
+ }
1163
+ const nextSeen = new Set(seen);
1164
+ nextSeen.add(key);
640
1165
  for (const { service, scope } of scopes) {
641
1166
  const envelope = readEnvelope(service, key);
642
1167
  if (!envelope) continue;
643
1168
  const decay = checkDecay(envelope);
644
1169
  if (decay.isExpired) {
645
- logAudit({
646
- action: "read",
647
- key,
648
- scope,
649
- source,
650
- detail: "blocked: secret expired (quantum decay)"
651
- });
1170
+ if (!opts.silent) {
1171
+ logAudit({
1172
+ action: "read",
1173
+ key,
1174
+ scope,
1175
+ source,
1176
+ detail: "blocked: secret expired (quantum decay)"
1177
+ });
1178
+ }
652
1179
  continue;
653
1180
  }
654
- const value = collapseValue(envelope, env);
1181
+ if (envelope.meta.requiresApproval && source === "mcp") {
1182
+ if (!hasApproval(key, scope)) {
1183
+ if (!opts.silent) {
1184
+ logAudit({
1185
+ action: "read",
1186
+ key,
1187
+ scope,
1188
+ source,
1189
+ detail: "blocked: requires user approval"
1190
+ });
1191
+ }
1192
+ throw new Error(`Access Denied: This secret requires user approval. Please ask the user to run 'qring approve ${key}'`);
1193
+ }
1194
+ }
1195
+ let value = collapseValue(envelope, env);
655
1196
  if (value === null) continue;
656
- const updated = recordAccess(envelope);
657
- writeEnvelope(service, key, updated);
658
- logAudit({ action: "read", key, scope, env, source });
1197
+ if (envelope.meta.jitProvider) {
1198
+ const provider = registry.get(envelope.meta.jitProvider);
1199
+ if (provider) {
1200
+ let isExpired = true;
1201
+ if (envelope.states && envelope.states["jit"] && envelope.meta.jitExpiresAt) {
1202
+ isExpired = new Date(envelope.meta.jitExpiresAt).getTime() <= Date.now();
1203
+ }
1204
+ if (isExpired) {
1205
+ const result = provider.provision(value);
1206
+ envelope.states = envelope.states ?? {};
1207
+ envelope.states["jit"] = result.value;
1208
+ envelope.meta.jitExpiresAt = result.expiresAt;
1209
+ writeEnvelope(service, key, envelope);
1210
+ }
1211
+ value = envelope.states["jit"];
1212
+ }
1213
+ }
1214
+ value = resolveTemplates(value, { ...opts, _seen: nextSeen }, nextSeen);
1215
+ if (!opts.silent) {
1216
+ const updated = recordAccess(envelope);
1217
+ writeEnvelope(service, key, updated);
1218
+ logAudit({ action: "read", key, scope, env, source });
1219
+ }
659
1220
  return value;
660
1221
  }
661
1222
  return null;
@@ -678,6 +1239,8 @@ function setSecret(key, value, opts = {}) {
678
1239
  const rotFmt = opts.rotationFormat ?? existing?.meta.rotationFormat;
679
1240
  const rotPfx = opts.rotationPrefix ?? existing?.meta.rotationPrefix;
680
1241
  const prov = opts.provider ?? existing?.meta.provider;
1242
+ const reqApp = opts.requiresApproval ?? existing?.meta.requiresApproval;
1243
+ const jitProv = opts.jitProvider ?? existing?.meta.jitProvider;
681
1244
  if (opts.states) {
682
1245
  envelope = createEnvelope("", {
683
1246
  states: opts.states,
@@ -689,7 +1252,9 @@ function setSecret(key, value, opts = {}) {
689
1252
  entangled: existing?.meta.entangled,
690
1253
  rotationFormat: rotFmt,
691
1254
  rotationPrefix: rotPfx,
692
- provider: prov
1255
+ provider: prov,
1256
+ requiresApproval: reqApp,
1257
+ jitProvider: jitProv
693
1258
  });
694
1259
  } else {
695
1260
  envelope = createEnvelope(value, {
@@ -700,7 +1265,9 @@ function setSecret(key, value, opts = {}) {
700
1265
  entangled: existing?.meta.entangled,
701
1266
  rotationFormat: rotFmt,
702
1267
  rotationPrefix: rotPfx,
703
- provider: prov
1268
+ provider: prov,
1269
+ requiresApproval: reqApp,
1270
+ jitProvider: jitProv
704
1271
  });
705
1272
  }
706
1273
  if (existing) {
@@ -788,6 +1355,12 @@ function listSecrets(opts = {}) {
788
1355
  scope: "project"
789
1356
  });
790
1357
  }
1358
+ if ((!opts.scope || opts.scope === "team") && opts.teamId) {
1359
+ services.push({ service: teamService(opts.teamId), scope: "team" });
1360
+ }
1361
+ if ((!opts.scope || opts.scope === "org") && opts.orgId) {
1362
+ services.push({ service: orgService(opts.orgId), scope: "org" });
1363
+ }
791
1364
  const results = [];
792
1365
  const seen = /* @__PURE__ */ new Set();
793
1366
  for (const { service, scope } of services) {
@@ -828,19 +1401,30 @@ function exportSecrets(opts = {}) {
828
1401
  (e) => opts.tags.some((t) => e.envelope?.meta.tags?.includes(t))
829
1402
  );
830
1403
  }
831
- const merged = /* @__PURE__ */ new Map();
1404
+ const rawValues = /* @__PURE__ */ new Map();
832
1405
  const globalEntries = entries.filter((e) => e.scope === "global");
1406
+ const orgEntries = entries.filter((e) => e.scope === "org");
1407
+ const teamEntries = entries.filter((e) => e.scope === "team");
833
1408
  const projectEntries = entries.filter((e) => e.scope === "project");
834
- for (const entry of [...globalEntries, ...projectEntries]) {
1409
+ for (const entry of [...globalEntries, ...orgEntries, ...teamEntries, ...projectEntries]) {
835
1410
  if (entry.envelope) {
836
1411
  const decay = checkDecay(entry.envelope);
837
1412
  if (decay.isExpired) continue;
838
1413
  const value = collapseValue(entry.envelope, env);
839
1414
  if (value !== null) {
840
- merged.set(entry.key, value);
1415
+ rawValues.set(entry.key, value);
841
1416
  }
842
1417
  }
843
1418
  }
1419
+ const merged = /* @__PURE__ */ new Map();
1420
+ for (const [key, value] of rawValues) {
1421
+ try {
1422
+ const resolved = resolveTemplatesOffline(value, rawValues, /* @__PURE__ */ new Set([key]));
1423
+ merged.set(key, resolved);
1424
+ } catch (err) {
1425
+ console.warn(`Warning: skipped exporting ${key} due to template error: ${err instanceof Error ? err.message : String(err)}`);
1426
+ }
1427
+ }
844
1428
  logAudit({ action: "export", source, detail: `format=${format}` });
845
1429
  if (format === "json") {
846
1430
  const obj = {};
@@ -960,14 +1544,21 @@ export {
960
1544
  collapseEnvironment,
961
1545
  logAudit,
962
1546
  queryAudit,
1547
+ verifyAuditChain,
1548
+ exportAudit,
963
1549
  detectAnomalies,
964
1550
  listEntanglements,
1551
+ httpRequest_,
965
1552
  registerHook,
966
1553
  removeHook,
967
1554
  listHooks,
968
- enableHook,
969
- disableHook,
970
1555
  fireHooks,
1556
+ registry,
1557
+ checkToolPolicy,
1558
+ checkKeyReadPolicy,
1559
+ checkExecPolicy,
1560
+ getExecMaxRuntime,
1561
+ getPolicySummary,
971
1562
  getSecret,
972
1563
  getEnvelope,
973
1564
  setSecret,
@@ -982,4 +1573,4 @@ export {
982
1573
  tunnelDestroy,
983
1574
  tunnelList
984
1575
  };
985
- //# sourceMappingURL=chunk-IGNU622R.js.map
1576
+ //# sourceMappingURL=chunk-5JBU7TWN.js.map