@i4ctime/q-ring 0.9.8 → 0.11.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.
@@ -1,6 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/version.ts
4
+ import { readFileSync } from "fs";
5
+ import { dirname, join } from "path";
6
+ import { fileURLToPath } from "url";
7
+ function readPackageVersion() {
8
+ try {
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ const pkgPath = join(here, "..", "package.json");
11
+ const raw = readFileSync(pkgPath, "utf8");
12
+ const pkg = JSON.parse(raw);
13
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
14
+ } catch {
15
+ return "0.0.0";
16
+ }
17
+ }
18
+ var PACKAGE_VERSION = readPackageVersion();
19
+
3
20
  // src/core/envelope.ts
21
+ import { z } from "zod";
22
+ var EntanglementLinkSchema = z.object({
23
+ service: z.string(),
24
+ key: z.string()
25
+ });
26
+ var SecretMetadataSchema = z.object({
27
+ createdAt: z.string(),
28
+ updatedAt: z.string(),
29
+ expiresAt: z.string().optional(),
30
+ ttlSeconds: z.number().optional(),
31
+ description: z.string().optional(),
32
+ tags: z.array(z.string()).optional(),
33
+ entangled: z.array(EntanglementLinkSchema).optional(),
34
+ accessCount: z.number(),
35
+ lastAccessedAt: z.string().optional(),
36
+ ephemeral: z.boolean().optional(),
37
+ rotationFormat: z.string().optional(),
38
+ rotationPrefix: z.string().optional(),
39
+ provider: z.string().optional(),
40
+ validationUrl: z.string().optional(),
41
+ requiresApproval: z.boolean().optional(),
42
+ jitProvider: z.string().optional(),
43
+ jitExpiresAt: z.string().optional()
44
+ });
45
+ var QuantumEnvelopeSchema = z.object({
46
+ v: z.literal(1),
47
+ value: z.string().optional(),
48
+ states: z.record(z.string(), z.string()).optional(),
49
+ defaultEnv: z.string().optional(),
50
+ meta: SecretMetadataSchema
51
+ });
4
52
  function createEnvelope(value, opts) {
5
53
  const now = (/* @__PURE__ */ new Date()).toISOString();
6
54
  let expiresAt = opts?.expiresAt;
@@ -32,8 +80,9 @@ function createEnvelope(value, opts) {
32
80
  function parseEnvelope(raw) {
33
81
  try {
34
82
  const parsed = JSON.parse(raw);
35
- if (parsed && typeof parsed === "object" && parsed.v === 1) {
36
- return parsed;
83
+ const r = QuantumEnvelopeSchema.safeParse(parsed);
84
+ if (r.success) {
85
+ return r.data;
37
86
  }
38
87
  } catch {
39
88
  }
@@ -84,6 +133,15 @@ function checkDecay(envelope) {
84
133
  const now = Date.now();
85
134
  const expires = new Date(envelope.meta.expiresAt).getTime();
86
135
  const created = new Date(envelope.meta.createdAt).getTime();
136
+ if (!Number.isFinite(expires) || !Number.isFinite(created)) {
137
+ return {
138
+ isExpired: true,
139
+ isStale: true,
140
+ lifetimePercent: 100,
141
+ secondsRemaining: null,
142
+ timeRemaining: "invalid date"
143
+ };
144
+ }
87
145
  const totalLifetime = expires - created;
88
146
  const elapsed = now - created;
89
147
  const remaining = expires - now;
@@ -121,8 +179,8 @@ function recordAccess(envelope) {
121
179
 
122
180
  // src/core/collapse.ts
123
181
  import { execSync } from "child_process";
124
- import { existsSync, readFileSync } from "fs";
125
- import { join } from "path";
182
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
183
+ import { join as join2 } from "path";
126
184
  var BRANCH_ENV_MAP = {
127
185
  main: "prod",
128
186
  master: "prod",
@@ -149,10 +207,10 @@ function detectGitBranch(cwd) {
149
207
  }
150
208
  }
151
209
  function readProjectConfig(projectPath) {
152
- const configPath = join(projectPath ?? process.cwd(), ".q-ring.json");
210
+ const configPath = join2(projectPath ?? process.cwd(), ".q-ring.json");
153
211
  try {
154
212
  if (existsSync(configPath)) {
155
- return JSON.parse(readFileSync(configPath, "utf8"));
213
+ return JSON.parse(readFileSync2(configPath, "utf8"));
156
214
  }
157
215
  } catch {
158
216
  }
@@ -188,12 +246,15 @@ function collapseEnvironment(ctx = {}) {
188
246
  }
189
247
  return null;
190
248
  }
249
+ var MAX_BRANCH_GLOB_PATTERN_LEN = 200;
250
+ var MAX_BRANCH_GLOB_REGEX_SOURCE = 400;
191
251
  function matchGlob(branchMap, branch) {
192
252
  for (const [pattern, env] of Object.entries(branchMap)) {
193
253
  if (!pattern.includes("*")) continue;
194
- const regex = new RegExp(
195
- "^" + pattern.replace(/\*/g, ".*") + "$"
196
- );
254
+ if (pattern.length > MAX_BRANCH_GLOB_PATTERN_LEN) continue;
255
+ const source = "^" + pattern.replace(/\*/g, ".*") + "$";
256
+ if (source.length > MAX_BRANCH_GLOB_REGEX_SOURCE) continue;
257
+ const regex = new RegExp(source);
197
258
  if (regex.test(branch)) return env;
198
259
  }
199
260
  return void 0;
@@ -206,19 +267,35 @@ function mapEnvName(raw) {
206
267
  }
207
268
 
208
269
  // src/core/observer.ts
209
- import { existsSync as existsSync2, mkdirSync, appendFileSync, readFileSync as readFileSync2, openSync, fstatSync, readSync, closeSync } from "fs";
210
- import { join as join2 } from "path";
270
+ import {
271
+ existsSync as existsSync2,
272
+ mkdirSync,
273
+ appendFileSync,
274
+ readFileSync as readFileSync3,
275
+ openSync,
276
+ fstatSync,
277
+ readSync,
278
+ closeSync,
279
+ statSync
280
+ } from "fs";
281
+ import { join as join3 } from "path";
211
282
  import { homedir } from "os";
212
283
  import { createHash } from "crypto";
213
284
  function getAuditDir() {
214
- const dir = join2(homedir(), ".config", "q-ring");
285
+ if (process.env.QRING_AUDIT_DIR) {
286
+ if (!existsSync2(process.env.QRING_AUDIT_DIR)) {
287
+ mkdirSync(process.env.QRING_AUDIT_DIR, { recursive: true });
288
+ }
289
+ return process.env.QRING_AUDIT_DIR;
290
+ }
291
+ const dir = join3(homedir(), ".config", "q-ring");
215
292
  if (!existsSync2(dir)) {
216
293
  mkdirSync(dir, { recursive: true });
217
294
  }
218
295
  return dir;
219
296
  }
220
297
  function getAuditPath() {
221
- return join2(getAuditDir(), "audit.jsonl");
298
+ return join3(getAuditDir(), "audit.jsonl");
222
299
  }
223
300
  function getLastLineHash() {
224
301
  const path = getAuditPath();
@@ -256,11 +333,24 @@ function logAudit(event) {
256
333
  } catch {
257
334
  }
258
335
  }
336
+ var MAX_AUDIT_BYTES = 12 * 1024 * 1024;
259
337
  function queryAudit(query = {}) {
260
338
  const path = getAuditPath();
261
339
  if (!existsSync2(path)) return [];
262
340
  try {
263
- const lines = readFileSync2(path, "utf8").split("\n").filter((l) => l.trim());
341
+ const st = statSync(path);
342
+ const readStart = st.size > MAX_AUDIT_BYTES ? st.size - MAX_AUDIT_BYTES : 0;
343
+ const readLen = st.size > MAX_AUDIT_BYTES ? MAX_AUDIT_BYTES : st.size;
344
+ const buf = Buffer.alloc(readLen);
345
+ const fd = openSync(path, "r");
346
+ readSync(fd, buf, 0, readLen, readStart);
347
+ closeSync(fd);
348
+ let text = buf.toString("utf8");
349
+ if (readStart > 0) {
350
+ const firstNl = text.indexOf("\n");
351
+ if (firstNl !== -1) text = text.slice(firstNl + 1);
352
+ }
353
+ const lines = text.split("\n").filter((l) => l.trim());
264
354
  let events = lines.map((line) => {
265
355
  try {
266
356
  return JSON.parse(line);
@@ -290,7 +380,7 @@ function verifyAuditChain() {
290
380
  if (!existsSync2(path)) {
291
381
  return { totalEvents: 0, validEvents: 0, intact: true };
292
382
  }
293
- const lines = readFileSync2(path, "utf8").split("\n").filter((l) => l.trim());
383
+ const lines = readFileSync3(path, "utf8").split("\n").filter((l) => l.trim());
294
384
  if (lines.length === 0) {
295
385
  return { totalEvents: 0, validEvents: 0, intact: true };
296
386
  }
@@ -328,7 +418,7 @@ function verifyAuditChain() {
328
418
  function exportAudit(opts = {}) {
329
419
  const path = getAuditPath();
330
420
  if (!existsSync2(path)) return opts.format === "json" ? "[]" : "";
331
- const lines = readFileSync2(path, "utf8").split("\n").filter((l) => l.trim());
421
+ const lines = readFileSync3(path, "utf8").split("\n").filter((l) => l.trim());
332
422
  let events = lines.map((l) => {
333
423
  try {
334
424
  return JSON.parse(l);
@@ -393,15 +483,15 @@ function detectAnomalies(key) {
393
483
  }
394
484
 
395
485
  // src/core/entanglement.ts
396
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
397
- import { join as join3 } from "path";
486
+ import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
487
+ import { join as join4 } from "path";
398
488
  import { homedir as homedir2 } from "os";
399
489
  function getRegistryPath() {
400
- const dir = join3(homedir2(), ".config", "q-ring");
490
+ const dir = join4(homedir2(), ".config", "q-ring");
401
491
  if (!existsSync3(dir)) {
402
492
  mkdirSync2(dir, { recursive: true });
403
493
  }
404
- return join3(dir, "entanglement.json");
494
+ return join4(dir, "entanglement.json");
405
495
  }
406
496
  function loadRegistry() {
407
497
  const path = getRegistryPath();
@@ -409,13 +499,15 @@ function loadRegistry() {
409
499
  return { pairs: [] };
410
500
  }
411
501
  try {
412
- return JSON.parse(readFileSync3(path, "utf8"));
502
+ return JSON.parse(readFileSync4(path, "utf8"));
413
503
  } catch {
414
504
  return { pairs: [] };
415
505
  }
416
506
  }
417
507
  function saveRegistry(registry2) {
418
- writeFileSync2(getRegistryPath(), JSON.stringify(registry2, null, 2));
508
+ writeFileSync(getRegistryPath(), JSON.stringify(registry2, null, 2), {
509
+ mode: 384
510
+ });
419
511
  }
420
512
  function entangle(source, target) {
421
513
  const registry2 = loadRegistry();
@@ -453,74 +545,19 @@ function listEntanglements() {
453
545
  return loadRegistry().pairs;
454
546
  }
455
547
 
456
- // src/core/keyring.ts
457
- import { Entry, findCredentials } from "@napi-rs/keyring";
458
-
459
- // src/utils/hash.ts
460
- import { createHash as createHash2 } from "crypto";
461
- function hashProjectPath(projectPath) {
462
- return createHash2("sha256").update(projectPath).digest("hex").slice(0, 12);
463
- }
464
-
465
- // src/core/scope.ts
466
- var SERVICE_PREFIX = "q-ring";
467
- function globalService() {
468
- return `${SERVICE_PREFIX}:global`;
469
- }
470
- function projectService(projectPath) {
471
- const hash = hashProjectPath(projectPath);
472
- return `${SERVICE_PREFIX}:project:${hash}`;
473
- }
474
- function teamService(teamId) {
475
- return `${SERVICE_PREFIX}:team:${teamId}`;
476
- }
477
- function orgService(orgId) {
478
- return `${SERVICE_PREFIX}:org:${orgId}`;
479
- }
480
- function resolveScope(opts) {
481
- const { scope, projectPath, teamId, orgId } = opts;
482
- if (scope === "global") {
483
- return [{ scope: "global", service: globalService() }];
484
- }
485
- if (scope === "project") {
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 }];
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 = [];
498
- if (projectPath) {
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 });
506
- }
507
- chain.push({ scope: "global", service: globalService() });
508
- return chain;
509
- }
510
-
511
548
  // src/core/hooks.ts
512
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
513
- import { join as join4 } from "path";
549
+ import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
550
+ import { join as join5 } from "path";
514
551
  import { homedir as homedir3 } from "os";
515
- import { exec, spawn } from "child_process";
552
+ import { execFile, spawn } from "child_process";
516
553
  import { randomUUID } from "crypto";
517
554
 
518
555
  // src/utils/http-request.ts
519
556
  import { request as httpsRequest } from "https";
520
- import { request as httpRequest } from "http";
557
+ import { request as httpRequestPlain } from "http";
521
558
  var DEFAULT_TIMEOUT_MS = 1e4;
522
559
  var DEFAULT_MAX_RESPONSE_BYTES = 65536;
523
- function httpRequest_(opts) {
560
+ function httpRequest(opts) {
524
561
  const {
525
562
  url,
526
563
  method = "GET",
@@ -535,7 +572,7 @@ function httpRequest_(opts) {
535
572
  reject(new Error(`Unsupported URL protocol: ${parsed.protocol}`));
536
573
  return;
537
574
  }
538
- const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequest;
575
+ const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequestPlain;
539
576
  const reqHeaders = { ...headers };
540
577
  if (body && !reqHeaders["Content-Length"]) {
541
578
  reqHeaders["Content-Length"] = Buffer.byteLength(body);
@@ -598,6 +635,19 @@ function httpRequest_(opts) {
598
635
 
599
636
  // src/core/ssrf.ts
600
637
  import { lookup } from "dns/promises";
638
+ import * as dns from "dns";
639
+ import { isIPv4, isIPv6 } from "net";
640
+ function lookupAddressesSync(hostname2) {
641
+ const lookupSync2 = dns.lookupSync;
642
+ return lookupSync2(hostname2, { all: true });
643
+ }
644
+ function isHostnameIpLiteral(hostname2) {
645
+ if (isIPv4(hostname2)) return true;
646
+ if (hostname2.startsWith("[") && hostname2.endsWith("]")) {
647
+ return isIPv6(hostname2.slice(1, -1));
648
+ }
649
+ return isIPv6(hostname2);
650
+ }
601
651
  function isPrivateIP(ip) {
602
652
  const octet = "(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})";
603
653
  const ipv4Re = new RegExp(`^::ffff:(${octet}\\.${octet}\\.${octet}\\.${octet})$`, "i");
@@ -618,40 +668,64 @@ async function checkSSRF(url) {
618
668
  if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
619
669
  try {
620
670
  const parsed = new URL(url);
621
- const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
622
- if (isPrivateIP(hostname)) {
623
- return `Blocked: URL resolves to private address (${hostname}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
671
+ const hostname2 = parsed.hostname.replace(/^\[|\]$/g, "");
672
+ if (isPrivateIP(hostname2)) {
673
+ return `Blocked: URL resolves to private address (${hostname2}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
624
674
  }
625
- const results = await lookup(hostname, { all: true });
675
+ const results = await lookup(hostname2, { all: true });
626
676
  for (const { address } of results) {
627
677
  if (isPrivateIP(address)) {
628
- return `Blocked: URL "${hostname}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
678
+ return `Blocked: URL "${hostname2}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
629
679
  }
630
680
  }
631
681
  } catch {
632
682
  }
633
683
  return null;
634
684
  }
635
- function checkSSRFSync(url) {
685
+ function checkJitHttpProvisionUrl(url) {
636
686
  if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
637
687
  try {
638
688
  const parsed = new URL(url);
639
- const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
640
- if (isPrivateIP(hostname)) {
641
- return `Blocked: URL resolves to private address (${hostname}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
689
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
690
+ return "Blocked: JIT HTTP provider only allows http: or https: URLs.";
691
+ }
692
+ const hostname2 = parsed.hostname.replace(/^\[|\]$/g, "");
693
+ if (!hostname2) {
694
+ return "Blocked: empty hostname in JIT URL.";
695
+ }
696
+ if (isPrivateIP(hostname2)) {
697
+ return `Blocked: URL resolves to private address (${hostname2}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
698
+ }
699
+ if (isHostnameIpLiteral(hostname2)) {
700
+ return null;
701
+ }
702
+ let results;
703
+ try {
704
+ results = lookupAddressesSync(hostname2);
705
+ } catch {
706
+ return `Blocked: DNS resolution failed for "${hostname2}" (JIT HTTP provisioning fails closed).`;
707
+ }
708
+ if (!results.length) {
709
+ return `Blocked: DNS returned no addresses for "${hostname2}".`;
710
+ }
711
+ for (const { address } of results) {
712
+ if (isPrivateIP(address)) {
713
+ return `Blocked: URL "${hostname2}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
714
+ }
642
715
  }
643
716
  } catch {
717
+ return "Blocked: malformed JIT HTTP URL.";
644
718
  }
645
719
  return null;
646
720
  }
647
721
 
648
722
  // src/core/hooks.ts
649
723
  function getRegistryPath2() {
650
- const dir = join4(homedir3(), ".config", "q-ring");
724
+ const dir = join5(homedir3(), ".config", "q-ring");
651
725
  if (!existsSync4(dir)) {
652
726
  mkdirSync3(dir, { recursive: true });
653
727
  }
654
- return join4(dir, "hooks.json");
728
+ return join5(dir, "hooks.json");
655
729
  }
656
730
  function loadRegistry2() {
657
731
  const path = getRegistryPath2();
@@ -659,13 +733,15 @@ function loadRegistry2() {
659
733
  return { hooks: [] };
660
734
  }
661
735
  try {
662
- return JSON.parse(readFileSync4(path, "utf8"));
736
+ return JSON.parse(readFileSync5(path, "utf8"));
663
737
  } catch {
664
738
  return { hooks: [] };
665
739
  }
666
740
  }
667
741
  function saveRegistry2(registry2) {
668
- writeFileSync3(getRegistryPath2(), JSON.stringify(registry2, null, 2));
742
+ writeFileSync2(getRegistryPath2(), JSON.stringify(registry2, null, 2), {
743
+ mode: 384
744
+ });
669
745
  }
670
746
  function registerHook(entry) {
671
747
  const registry2 = loadRegistry2();
@@ -721,6 +797,14 @@ function matchesHook(hook, payload, tags) {
721
797
  if (m.scope && m.scope !== payload.scope) return false;
722
798
  return true;
723
799
  }
800
+ var SAFE_PGREP_NAME = /^[a-zA-Z0-9._:/-]{1,256}$/;
801
+ function shellRunner() {
802
+ if (process.platform === "win32") {
803
+ const comspec = process.env.ComSpec ?? "cmd.exe";
804
+ return { file: comspec, args: ["/d", "/s", "/c"] };
805
+ }
806
+ return { file: "/bin/sh", args: ["-c"] };
807
+ }
724
808
  function executeShell(command, payload) {
725
809
  return new Promise((resolve) => {
726
810
  const env = {
@@ -729,13 +813,33 @@ function executeShell(command, payload) {
729
813
  QRING_HOOK_ACTION: payload.action,
730
814
  QRING_HOOK_SCOPE: payload.scope
731
815
  };
732
- exec(command, { timeout: 3e4, env }, (err, stdout, stderr) => {
733
- if (err) {
734
- resolve({ hookId: "", success: false, message: `Shell error: ${err.message}` });
735
- } else {
736
- resolve({ hookId: "", success: true, message: stdout.trim() || "OK" });
816
+ const { file, args } = shellRunner();
817
+ execFile(
818
+ file,
819
+ [...args, command],
820
+ {
821
+ timeout: 3e4,
822
+ env,
823
+ windowsHide: true,
824
+ maxBuffer: 4 * 1024 * 1024,
825
+ encoding: "utf8"
826
+ },
827
+ (err, stdout) => {
828
+ if (err) {
829
+ resolve({
830
+ hookId: "",
831
+ success: false,
832
+ message: `Shell error: ${err.message}`
833
+ });
834
+ } else {
835
+ resolve({
836
+ hookId: "",
837
+ success: true,
838
+ message: (stdout ?? "").trim() || "OK"
839
+ });
840
+ }
737
841
  }
738
- });
842
+ );
739
843
  });
740
844
  }
741
845
  async function executeHttp(url, payload) {
@@ -752,7 +856,7 @@ async function executeHttp(url, payload) {
752
856
  }
753
857
  try {
754
858
  const body = JSON.stringify(payload);
755
- const res = await httpRequest_({
859
+ const res = await httpRequest({
756
860
  url,
757
861
  method: "POST",
758
862
  headers: {
@@ -777,24 +881,48 @@ async function executeHttp(url, payload) {
777
881
  }
778
882
  function executeSignal(target, signal = "SIGHUP") {
779
883
  return new Promise((resolve) => {
780
- const pid = parseInt(target, 10);
781
- if (!isNaN(pid)) {
884
+ const trimmed = target.trim();
885
+ if (/^\d+$/.test(trimmed)) {
886
+ const pid = parseInt(trimmed, 10);
782
887
  try {
783
888
  process.kill(pid, signal);
784
- resolve({ hookId: "", success: true, message: `Signal ${signal} sent to PID ${pid}` });
889
+ resolve({
890
+ hookId: "",
891
+ success: true,
892
+ message: `Signal ${signal} sent to PID ${pid}`
893
+ });
785
894
  } catch (err) {
786
- resolve({ hookId: "", success: false, message: `Signal error: ${err instanceof Error ? err.message : String(err)}` });
895
+ resolve({
896
+ hookId: "",
897
+ success: false,
898
+ message: `Signal error: ${err instanceof Error ? err.message : String(err)}`
899
+ });
787
900
  }
788
901
  return;
789
902
  }
790
- const child = spawn("pgrep", ["-f", target], { timeout: 5e3, stdio: ["ignore", "pipe", "pipe"] });
903
+ if (!SAFE_PGREP_NAME.test(trimmed)) {
904
+ resolve({
905
+ hookId: "",
906
+ success: false,
907
+ message: "Signal target must be a numeric PID, or a process name matching /^[a-zA-Z0-9._:/-]{1,256}$/ (no spaces or shell metacharacters)."
908
+ });
909
+ return;
910
+ }
911
+ const child = spawn("pgrep", ["-f", trimmed], {
912
+ timeout: 5e3,
913
+ stdio: ["ignore", "pipe", "pipe"]
914
+ });
791
915
  let stdout = "";
792
916
  child.stdout.on("data", (d) => {
793
917
  stdout += d.toString();
794
918
  });
795
919
  child.on("close", (code) => {
796
920
  if (code !== 0 || !stdout.trim()) {
797
- resolve({ hookId: "", success: false, message: `Process "${target}" not found` });
921
+ resolve({
922
+ hookId: "",
923
+ success: false,
924
+ message: `Process "${trimmed}" not found`
925
+ });
798
926
  return;
799
927
  }
800
928
  const pids = stdout.trim().split("\n").map((p) => parseInt(p.trim(), 10)).filter((p) => !isNaN(p));
@@ -806,10 +934,18 @@ function executeSignal(target, signal = "SIGHUP") {
806
934
  } catch {
807
935
  }
808
936
  }
809
- resolve({ hookId: "", success: sent > 0, message: `Signal ${signal} sent to ${sent} process(es)` });
937
+ resolve({
938
+ hookId: "",
939
+ success: sent > 0,
940
+ message: `Signal ${signal} sent to ${sent} process(es)`
941
+ });
810
942
  });
811
943
  child.on("error", () => {
812
- resolve({ hookId: "", success: false, message: `Process "${target}" not found` });
944
+ resolve({
945
+ hookId: "",
946
+ success: false,
947
+ message: `Process "${trimmed}" not found`
948
+ });
813
949
  });
814
950
  });
815
951
  }
@@ -866,35 +1002,52 @@ async function fireHooks(payload, tags) {
866
1002
  }
867
1003
 
868
1004
  // src/core/approval.ts
869
- import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
870
- import { join as join5 } from "path";
1005
+ import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
1006
+ import { join as join6 } from "path";
871
1007
  import { homedir as homedir4 } from "os";
872
- import { createHmac, randomBytes } from "crypto";
1008
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
873
1009
  function getHmacSecret() {
874
- const secretPath = join5(homedir4(), ".config", "q-ring", ".approval-key");
875
- const dir = join5(homedir4(), ".config", "q-ring");
876
- if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true });
1010
+ const dir = join6(homedir4(), ".config", "q-ring");
1011
+ const secretPath = join6(dir, ".approval-key");
1012
+ if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
877
1013
  if (existsSync5(secretPath)) {
878
- return readFileSync5(secretPath, "utf8").trim();
1014
+ return readFileSync6(secretPath, "utf8").trim();
879
1015
  }
880
1016
  const secret = randomBytes(32).toString("hex");
881
- writeFileSync4(secretPath, secret, { mode: 384 });
1017
+ writeFileSync3(secretPath, secret, { mode: 384 });
882
1018
  return secret;
883
1019
  }
884
1020
  function computeHmac(entry) {
885
- const payload = `${entry.id}|${entry.key}|${entry.scope}|${entry.reason}|${entry.grantedBy}|${entry.grantedAt}|${entry.expiresAt}`;
1021
+ const payload = [
1022
+ entry.id,
1023
+ entry.key,
1024
+ entry.scope,
1025
+ entry.reason,
1026
+ entry.grantedBy,
1027
+ entry.grantedAt,
1028
+ entry.expiresAt,
1029
+ entry.workspace ?? "",
1030
+ entry.sessionId ?? ""
1031
+ ].join("|");
886
1032
  return createHmac("sha256", getHmacSecret()).update(payload).digest("hex");
887
1033
  }
888
1034
  function verifyHmac(entry) {
889
1035
  const expected = computeHmac(entry);
890
- return expected === entry.hmac;
1036
+ const a = Buffer.from(expected, "utf8");
1037
+ const b = Buffer.from(entry.hmac, "utf8");
1038
+ if (a.length !== b.length) return false;
1039
+ try {
1040
+ return timingSafeEqual(a, b);
1041
+ } catch {
1042
+ return false;
1043
+ }
891
1044
  }
892
1045
  function getRegistryPath3() {
893
- const dir = join5(homedir4(), ".config", "q-ring");
1046
+ const dir = join6(homedir4(), ".config", "q-ring");
894
1047
  if (!existsSync5(dir)) {
895
- mkdirSync4(dir, { recursive: true });
1048
+ mkdirSync4(dir, { recursive: true, mode: 448 });
896
1049
  }
897
- return join5(dir, "approvals.json");
1050
+ return join6(dir, "approvals.json");
898
1051
  }
899
1052
  function loadRegistry3() {
900
1053
  const path = getRegistryPath3();
@@ -902,13 +1055,13 @@ function loadRegistry3() {
902
1055
  return { approvals: [] };
903
1056
  }
904
1057
  try {
905
- return JSON.parse(readFileSync5(path, "utf8"));
1058
+ return JSON.parse(readFileSync6(path, "utf8"));
906
1059
  } catch {
907
1060
  return { approvals: [] };
908
1061
  }
909
1062
  }
910
1063
  function saveRegistry3(registry2) {
911
- writeFileSync4(getRegistryPath3(), JSON.stringify(registry2, null, 2), { mode: 384 });
1064
+ writeFileSync3(getRegistryPath3(), JSON.stringify(registry2, null, 2), { mode: 384 });
912
1065
  }
913
1066
  function cleanup(registry2) {
914
1067
  const now = Date.now();
@@ -974,8 +1127,192 @@ function listApprovals() {
974
1127
  }));
975
1128
  }
976
1129
 
1130
+ // src/core/policy.ts
1131
+ var cachedPolicy = null;
1132
+ function loadPolicy(projectPath) {
1133
+ const pp = projectPath ?? process.cwd();
1134
+ if (cachedPolicy && cachedPolicy.path === pp) {
1135
+ return cachedPolicy.policy;
1136
+ }
1137
+ const config = readProjectConfig(pp);
1138
+ const policy = config?.policy ?? {};
1139
+ cachedPolicy = { path: pp, policy };
1140
+ return policy;
1141
+ }
1142
+ function checkSecretLifecyclePolicy(input, projectPath) {
1143
+ const policy = loadPolicy(projectPath);
1144
+ if (!policy.secrets) return { allowed: true, policySource: "no-policy" };
1145
+ const s = policy.secrets;
1146
+ if (s.maxTtlSeconds != null && input.ttlSeconds != null && input.ttlSeconds > s.maxTtlSeconds) {
1147
+ return {
1148
+ allowed: false,
1149
+ reason: `TTL ${input.ttlSeconds}s exceeds policy maximum ${s.maxTtlSeconds}s`,
1150
+ policySource: ".q-ring.json policy.secrets.maxTtlSeconds"
1151
+ };
1152
+ }
1153
+ if (s.requireApprovalForTags?.length && input.tags?.length) {
1154
+ const hit = input.tags.find((t) => s.requireApprovalForTags.includes(t));
1155
+ if (hit && !input.requiresApproval) {
1156
+ return {
1157
+ allowed: false,
1158
+ reason: `Tag "${hit}" requires explicit approval metadata (set requiresApproval / --requires-approval)`,
1159
+ policySource: ".q-ring.json policy.secrets.requireApprovalForTags"
1160
+ };
1161
+ }
1162
+ }
1163
+ if (s.requireRotationFormatForTags?.length && input.tags?.length) {
1164
+ const hit = input.tags.find((t) => s.requireRotationFormatForTags.includes(t));
1165
+ if (hit && !input.rotationFormat) {
1166
+ return {
1167
+ allowed: false,
1168
+ reason: `Tag "${hit}" requires a rotationFormat to be set`,
1169
+ policySource: ".q-ring.json policy.secrets.requireRotationFormatForTags"
1170
+ };
1171
+ }
1172
+ }
1173
+ return { allowed: true, policySource: ".q-ring.json" };
1174
+ }
1175
+ function checkKeyReadPolicy(key, tags, projectPath) {
1176
+ const policy = loadPolicy(projectPath);
1177
+ if (!policy.mcp) return { allowed: true, policySource: "no-policy" };
1178
+ if (policy.mcp.deniedKeys?.includes(key)) {
1179
+ return {
1180
+ allowed: false,
1181
+ reason: `Key "${key}" is denied by project policy`,
1182
+ policySource: ".q-ring.json policy.mcp.deniedKeys"
1183
+ };
1184
+ }
1185
+ if (policy.mcp.readableKeys && !policy.mcp.readableKeys.includes(key)) {
1186
+ return {
1187
+ allowed: false,
1188
+ reason: `Key "${key}" is not in the readable keys allowlist`,
1189
+ policySource: ".q-ring.json policy.mcp.readableKeys"
1190
+ };
1191
+ }
1192
+ if (tags && policy.mcp.deniedTags) {
1193
+ const blocked = tags.find((t) => policy.mcp.deniedTags.includes(t));
1194
+ if (blocked) {
1195
+ return {
1196
+ allowed: false,
1197
+ reason: `Tag "${blocked}" is denied by project policy`,
1198
+ policySource: ".q-ring.json policy.mcp.deniedTags"
1199
+ };
1200
+ }
1201
+ }
1202
+ return { allowed: true, policySource: ".q-ring.json" };
1203
+ }
1204
+ function checkExecPolicy(command, projectPath) {
1205
+ const policy = loadPolicy(projectPath);
1206
+ if (!policy.exec) return { allowed: true, policySource: "no-policy" };
1207
+ if (policy.exec.denyCommands) {
1208
+ const denied = policy.exec.denyCommands.find((d) => command.includes(d));
1209
+ if (denied) {
1210
+ return {
1211
+ allowed: false,
1212
+ reason: `Command containing "${denied}" is denied by project policy`,
1213
+ policySource: ".q-ring.json policy.exec.denyCommands"
1214
+ };
1215
+ }
1216
+ }
1217
+ if (policy.exec.allowCommands) {
1218
+ const allowed = policy.exec.allowCommands.some((a) => command.startsWith(a));
1219
+ if (!allowed) {
1220
+ return {
1221
+ allowed: false,
1222
+ reason: `Command "${command}" is not in the exec allowlist`,
1223
+ policySource: ".q-ring.json policy.exec.allowCommands"
1224
+ };
1225
+ }
1226
+ }
1227
+ return { allowed: true, policySource: ".q-ring.json" };
1228
+ }
1229
+ function getExecMaxRuntime(projectPath) {
1230
+ return loadPolicy(projectPath).exec?.maxRuntimeSeconds;
1231
+ }
1232
+ function getPolicySummary(projectPath) {
1233
+ const policy = loadPolicy(projectPath);
1234
+ return {
1235
+ hasMcpPolicy: !!policy.mcp,
1236
+ hasExecPolicy: !!policy.exec,
1237
+ hasSecretPolicy: !!policy.secrets,
1238
+ details: policy
1239
+ };
1240
+ }
1241
+
1242
+ // src/core/keyring.ts
1243
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, unlinkSync } from "fs";
1244
+ import { homedir as homedir5 } from "os";
1245
+ import { join as join7 } from "path";
1246
+ import { Entry, findCredentials } from "@napi-rs/keyring";
1247
+
1248
+ // src/utils/hash.ts
1249
+ import { createHash as createHash2 } from "crypto";
1250
+ function hashProjectPath(projectPath) {
1251
+ return createHash2("sha256").update(projectPath).digest("hex").slice(0, 12);
1252
+ }
1253
+
1254
+ // src/core/scope.ts
1255
+ var SERVICE_PREFIX = "q-ring";
1256
+ function globalService() {
1257
+ return `${SERVICE_PREFIX}:global`;
1258
+ }
1259
+ function projectService(projectPath) {
1260
+ const hash = hashProjectPath(projectPath);
1261
+ return `${SERVICE_PREFIX}:project:${hash}`;
1262
+ }
1263
+ function teamService(teamId) {
1264
+ return `${SERVICE_PREFIX}:team:${teamId}`;
1265
+ }
1266
+ function orgService(orgId) {
1267
+ return `${SERVICE_PREFIX}:org:${orgId}`;
1268
+ }
1269
+ function resolveScope(opts) {
1270
+ const { scope, projectPath, teamId, orgId } = opts;
1271
+ if (scope === "global") {
1272
+ return [{ scope: "global", service: globalService() }];
1273
+ }
1274
+ if (scope === "project") {
1275
+ if (!projectPath) throw new Error("Project path is required for project scope");
1276
+ return [{ scope: "project", service: projectService(projectPath), projectPath }];
1277
+ }
1278
+ if (scope === "team") {
1279
+ if (!teamId) throw new Error("Team ID is required for team scope");
1280
+ return [{ scope: "team", service: teamService(teamId), teamId }];
1281
+ }
1282
+ if (scope === "org") {
1283
+ if (!orgId) throw new Error("Org ID is required for org scope");
1284
+ return [{ scope: "org", service: orgService(orgId), orgId }];
1285
+ }
1286
+ const chain = [];
1287
+ if (projectPath) {
1288
+ chain.push({ scope: "project", service: projectService(projectPath), projectPath });
1289
+ }
1290
+ if (teamId) {
1291
+ chain.push({ scope: "team", service: teamService(teamId), teamId });
1292
+ }
1293
+ if (orgId) {
1294
+ chain.push({ scope: "org", service: orgService(orgId), orgId });
1295
+ }
1296
+ chain.push({ scope: "global", service: globalService() });
1297
+ return chain;
1298
+ }
1299
+
977
1300
  // src/core/provision.ts
978
1301
  import { execFileSync, spawnSync } from "child_process";
1302
+ import { z as z2 } from "zod";
1303
+ var AwsStsConfigSchema = z2.object({
1304
+ roleArn: z2.string(),
1305
+ sessionName: z2.string().optional(),
1306
+ durationSeconds: z2.number().optional()
1307
+ });
1308
+ var HttpJitConfigSchema = z2.object({
1309
+ url: z2.string(),
1310
+ method: z2.string().optional(),
1311
+ valuePath: z2.string().optional(),
1312
+ expiresInSeconds: z2.number().optional(),
1313
+ headers: z2.record(z2.string(), z2.string()).optional(),
1314
+ body: z2.unknown().optional()
1315
+ });
979
1316
  var ProvisionRegistry = class {
980
1317
  providers = /* @__PURE__ */ new Map();
981
1318
  register(provider) {
@@ -992,16 +1329,20 @@ var awsStsProvider = {
992
1329
  name: "aws-sts",
993
1330
  description: "AWS STS AssumeRole (requires existing local AWS CLI credentials)",
994
1331
  provision(configRaw) {
995
- let config;
1332
+ let raw;
996
1333
  try {
997
- config = JSON.parse(configRaw);
1334
+ raw = JSON.parse(configRaw);
998
1335
  } catch {
999
1336
  throw new Error('aws-sts requires valid JSON config (e.g. {"roleArn":"arn:aws:..."})');
1000
1337
  }
1338
+ const parsed = AwsStsConfigSchema.safeParse(raw);
1339
+ if (!parsed.success) {
1340
+ throw new Error(`aws-sts invalid config: ${parsed.error.message}`);
1341
+ }
1342
+ const config = parsed.data;
1001
1343
  const roleArn = config.roleArn;
1002
1344
  const sessionName = config.sessionName || "q-ring-agent";
1003
1345
  const duration = config.durationSeconds || 3600;
1004
- if (!roleArn) throw new Error("aws-sts requires roleArn in config");
1005
1346
  try {
1006
1347
  const output = execFileSync("aws", [
1007
1348
  "sts",
@@ -1015,8 +1356,8 @@ var awsStsProvider = {
1015
1356
  "--output",
1016
1357
  "json"
1017
1358
  ], { encoding: "utf8" });
1018
- const parsed = JSON.parse(output);
1019
- const creds = parsed.Credentials;
1359
+ const parsed2 = JSON.parse(output);
1360
+ const creds = parsed2.Credentials;
1020
1361
  const value = JSON.stringify({
1021
1362
  AWS_ACCESS_KEY_ID: creds.AccessKeyId,
1022
1363
  AWS_SECRET_ACCESS_KEY: creds.SecretAccessKey,
@@ -1035,18 +1376,23 @@ var httpProvider = {
1035
1376
  name: "http",
1036
1377
  description: "Generic HTTP token endpoint using Node.js http/https",
1037
1378
  provision(configRaw) {
1038
- let config;
1379
+ let raw;
1039
1380
  try {
1040
- config = JSON.parse(configRaw);
1381
+ raw = JSON.parse(configRaw);
1041
1382
  } catch {
1042
1383
  throw new Error("http provider requires valid JSON config");
1043
1384
  }
1385
+ const parsed = HttpJitConfigSchema.safeParse(raw);
1386
+ if (!parsed.success) {
1387
+ throw new Error(`http provider invalid config: ${parsed.error.message}`);
1388
+ }
1389
+ const config = parsed.data;
1044
1390
  const url = config.url;
1045
1391
  const method = config.method || "POST";
1046
1392
  const valuePath = config.valuePath || "token";
1047
1393
  const expiresInSeconds = config.expiresInSeconds || 3600;
1048
1394
  if (!url) throw new Error("http provider requires url in config");
1049
- const ssrfBlock = checkSSRFSync(url);
1395
+ const ssrfBlock = checkJitHttpProvisionUrl(url);
1050
1396
  if (ssrfBlock) throw new Error(`SSRF blocked: ${ssrfBlock}`);
1051
1397
  const headers = {
1052
1398
  "User-Agent": "q-ring-jit/1.0",
@@ -1080,8 +1426,8 @@ req.end();
1080
1426
  if (result.status !== 0) {
1081
1427
  throw new Error(result.stderr || "HTTP request failed");
1082
1428
  }
1083
- const parsed = JSON.parse(result.stdout);
1084
- let val = parsed;
1429
+ const parsed2 = JSON.parse(result.stdout);
1430
+ let val = parsed2;
1085
1431
  for (const key of valuePath.split(".")) {
1086
1432
  val = val[key];
1087
1433
  }
@@ -1098,86 +1444,33 @@ var registry = new ProvisionRegistry();
1098
1444
  registry.register(awsStsProvider);
1099
1445
  registry.register(httpProvider);
1100
1446
 
1101
- // src/core/policy.ts
1102
- var cachedPolicy = null;
1103
- function loadPolicy(projectPath) {
1104
- const pp = projectPath ?? process.cwd();
1105
- if (cachedPolicy && cachedPolicy.path === pp) {
1106
- return cachedPolicy.policy;
1107
- }
1108
- const config = readProjectConfig(pp);
1109
- const policy = config?.policy ?? {};
1110
- cachedPolicy = { path: pp, policy };
1111
- return policy;
1112
- }
1113
- function checkKeyReadPolicy(key, tags, projectPath) {
1114
- const policy = loadPolicy(projectPath);
1115
- if (!policy.mcp) return { allowed: true, policySource: "no-policy" };
1116
- if (policy.mcp.deniedKeys?.includes(key)) {
1117
- return {
1118
- allowed: false,
1119
- reason: `Key "${key}" is denied by project policy`,
1120
- policySource: ".q-ring.json policy.mcp.deniedKeys"
1121
- };
1122
- }
1123
- if (policy.mcp.readableKeys && !policy.mcp.readableKeys.includes(key)) {
1124
- return {
1125
- allowed: false,
1126
- reason: `Key "${key}" is not in the readable keys allowlist`,
1127
- policySource: ".q-ring.json policy.mcp.readableKeys"
1128
- };
1129
- }
1130
- if (tags && policy.mcp.deniedTags) {
1131
- const blocked = tags.find((t) => policy.mcp.deniedTags.includes(t));
1132
- if (blocked) {
1133
- return {
1134
- allowed: false,
1135
- reason: `Tag "${blocked}" is denied by project policy`,
1136
- policySource: ".q-ring.json policy.mcp.deniedTags"
1137
- };
1138
- }
1139
- }
1140
- return { allowed: true, policySource: ".q-ring.json" };
1141
- }
1142
- function checkExecPolicy(command, projectPath) {
1143
- const policy = loadPolicy(projectPath);
1144
- if (!policy.exec) return { allowed: true, policySource: "no-policy" };
1145
- if (policy.exec.denyCommands) {
1146
- const denied = policy.exec.denyCommands.find((d) => command.includes(d));
1147
- if (denied) {
1148
- return {
1149
- allowed: false,
1150
- reason: `Command containing "${denied}" is denied by project policy`,
1151
- policySource: ".q-ring.json policy.exec.denyCommands"
1152
- };
1153
- }
1154
- }
1155
- if (policy.exec.allowCommands) {
1156
- const allowed = policy.exec.allowCommands.some((a) => command.startsWith(a));
1157
- if (!allowed) {
1158
- return {
1159
- allowed: false,
1160
- reason: `Command "${command}" is not in the exec allowlist`,
1161
- policySource: ".q-ring.json policy.exec.allowCommands"
1162
- };
1447
+ // src/core/keyring.ts
1448
+ function withJitEnvelopeLock(service, key, fn) {
1449
+ const dir = join7(homedir5(), ".config", "q-ring", "jit-locks");
1450
+ mkdirSync5(dir, { recursive: true });
1451
+ const safe = Buffer.from(`${service}\0${key}`, "utf8").toString("base64url");
1452
+ const lockPath = join7(dir, `${safe}.lock`);
1453
+ const deadline = Date.now() + 8e3;
1454
+ while (Date.now() < deadline) {
1455
+ try {
1456
+ writeFileSync4(lockPath, `${process.pid}
1457
+ `, { flag: "wx", mode: 384 });
1458
+ try {
1459
+ return fn();
1460
+ } finally {
1461
+ try {
1462
+ unlinkSync(lockPath);
1463
+ } catch {
1464
+ }
1465
+ }
1466
+ } catch {
1467
+ const start = Date.now();
1468
+ while (Date.now() - start < 15) {
1469
+ }
1163
1470
  }
1164
1471
  }
1165
- return { allowed: true, policySource: ".q-ring.json" };
1472
+ throw new Error("Could not acquire JIT envelope lock (timeout)");
1166
1473
  }
1167
- function getExecMaxRuntime(projectPath) {
1168
- return loadPolicy(projectPath).exec?.maxRuntimeSeconds;
1169
- }
1170
- function getPolicySummary(projectPath) {
1171
- const policy = loadPolicy(projectPath);
1172
- return {
1173
- hasMcpPolicy: !!policy.mcp,
1174
- hasExecPolicy: !!policy.exec,
1175
- hasSecretPolicy: !!policy.secrets,
1176
- details: policy
1177
- };
1178
- }
1179
-
1180
- // src/core/keyring.ts
1181
1474
  function readEnvelope(service, key) {
1182
1475
  const entry = new Entry(service, key);
1183
1476
  const raw = entry.getPassword();
@@ -1196,7 +1489,7 @@ function resolveEnv(opts) {
1196
1489
  }
1197
1490
  function resolveTemplates(value, opts, seen) {
1198
1491
  if (!value.includes("{{") || !value.includes("}}")) return value;
1199
- return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1492
+ return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
1200
1493
  const refKey = refKeyRaw.trim();
1201
1494
  const refValue = getSecret(refKey, { ...opts, _seen: seen });
1202
1495
  if (refValue === null) {
@@ -1207,7 +1500,7 @@ function resolveTemplates(value, opts, seen) {
1207
1500
  }
1208
1501
  function resolveTemplatesOffline(value, rawValues, seen) {
1209
1502
  if (!value.includes("{{") || !value.includes("}}")) return value;
1210
- return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1503
+ return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
1211
1504
  const refKey = refKeyRaw.trim();
1212
1505
  if (seen.has(refKey)) {
1213
1506
  throw new Error(`Circular dependency detected: ${[...seen].join(" -> ")} -> ${refKey}`);
@@ -1277,11 +1570,27 @@ function getSecret(key, opts = {}) {
1277
1570
  isExpired = new Date(envelope.meta.jitExpiresAt).getTime() <= Date.now();
1278
1571
  }
1279
1572
  if (isExpired) {
1280
- const result = provider.provision(value);
1281
- envelope.states = envelope.states ?? {};
1282
- envelope.states["jit"] = result.value;
1283
- envelope.meta.jitExpiresAt = result.expiresAt;
1284
- writeEnvelope(service, key, envelope);
1573
+ const jitConfigSource = value;
1574
+ withJitEnvelopeLock(service, key, () => {
1575
+ const latest = readEnvelope(service, key);
1576
+ if (!latest?.meta.jitProvider) return;
1577
+ let innerExpired = true;
1578
+ if (latest.states && latest.states["jit"] && latest.meta.jitExpiresAt) {
1579
+ innerExpired = new Date(latest.meta.jitExpiresAt).getTime() <= Date.now();
1580
+ }
1581
+ if (!innerExpired) {
1582
+ envelope.states = latest.states;
1583
+ envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
1584
+ return;
1585
+ }
1586
+ const result = provider.provision(jitConfigSource);
1587
+ latest.states = latest.states ?? {};
1588
+ latest.states["jit"] = result.value;
1589
+ latest.meta.jitExpiresAt = result.expiresAt;
1590
+ writeEnvelope(service, key, latest);
1591
+ envelope.states = latest.states;
1592
+ envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
1593
+ });
1285
1594
  }
1286
1595
  value = envelope.states["jit"];
1287
1596
  }
@@ -1323,6 +1632,20 @@ function setSecret(key, value, opts = {}) {
1323
1632
  const prov = opts.provider ?? existing?.meta.provider;
1324
1633
  const reqApp = opts.requiresApproval ?? existing?.meta.requiresApproval;
1325
1634
  const jitProv = opts.jitProvider ?? existing?.meta.jitProvider;
1635
+ const mergedTags = opts.tags ?? existing?.meta.tags;
1636
+ const ttlForPolicy = opts.ttlSeconds ?? existing?.meta.ttlSeconds;
1637
+ const life = checkSecretLifecyclePolicy(
1638
+ {
1639
+ tags: mergedTags,
1640
+ ttlSeconds: ttlForPolicy,
1641
+ rotationFormat: rotFmt,
1642
+ requiresApproval: !!reqApp
1643
+ },
1644
+ opts.projectPath
1645
+ );
1646
+ if (!life.allowed) {
1647
+ throw new Error(life.reason ?? "Secret lifecycle policy denied");
1648
+ }
1326
1649
  if (opts.states) {
1327
1650
  envelope = createEnvelope("", {
1328
1651
  states: opts.states,
@@ -1639,7 +1962,125 @@ function tunnelList() {
1639
1962
  return result;
1640
1963
  }
1641
1964
 
1965
+ // src/core/memory.ts
1966
+ import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5, mkdirSync as mkdirSync6 } from "fs";
1967
+ import { join as join8 } from "path";
1968
+ import { homedir as homedir6, hostname, userInfo } from "os";
1969
+ import { createCipheriv, createDecipheriv, createHash as createHash3, randomBytes as randomBytes3 } from "crypto";
1970
+ import { Entry as Entry2 } from "@napi-rs/keyring";
1971
+ var MEMORY_FILE = "agent-memory.enc";
1972
+ var KEYRING_SERVICE = "qring-memory-key";
1973
+ var KEYRING_ACCOUNT = "encryption-key";
1974
+ function getMemoryDir() {
1975
+ const dir = join8(homedir6(), ".config", "q-ring");
1976
+ if (!existsSync6(dir)) {
1977
+ mkdirSync6(dir, { recursive: true });
1978
+ }
1979
+ return dir;
1980
+ }
1981
+ function getMemoryPath() {
1982
+ return join8(getMemoryDir(), MEMORY_FILE);
1983
+ }
1984
+ function deriveLegacyKey() {
1985
+ const fingerprint = `qring-memory:${hostname()}:${userInfo().username}`;
1986
+ return createHash3("sha256").update(fingerprint).digest();
1987
+ }
1988
+ function getOrCreateKey() {
1989
+ try {
1990
+ const entry = new Entry2(KEYRING_SERVICE, KEYRING_ACCOUNT);
1991
+ const stored = entry.getPassword();
1992
+ if (stored) return Buffer.from(stored, "base64");
1993
+ const key = randomBytes3(32);
1994
+ entry.setPassword(key.toString("base64"));
1995
+ return key;
1996
+ } catch {
1997
+ console.warn("[q-ring] OS keyring unavailable for memory key \u2014 falling back to machine-derived key");
1998
+ return deriveLegacyKey();
1999
+ }
2000
+ }
2001
+ function encryptWith(data, key) {
2002
+ const iv = randomBytes3(12);
2003
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
2004
+ const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]);
2005
+ const tag = cipher.getAuthTag();
2006
+ return `${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
2007
+ }
2008
+ function decryptWith(blob, key) {
2009
+ const parts = blob.split(":");
2010
+ if (parts.length !== 3) throw new Error("Invalid encrypted format");
2011
+ const iv = Buffer.from(parts[0], "base64");
2012
+ const tag = Buffer.from(parts[1], "base64");
2013
+ const encrypted = Buffer.from(parts[2], "base64");
2014
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
2015
+ decipher.setAuthTag(tag);
2016
+ return decipher.update(encrypted) + decipher.final("utf8");
2017
+ }
2018
+ function encrypt(data) {
2019
+ return encryptWith(data, getOrCreateKey());
2020
+ }
2021
+ function decrypt(blob) {
2022
+ const key = getOrCreateKey();
2023
+ try {
2024
+ return decryptWith(blob, key);
2025
+ } catch {
2026
+ const legacy = deriveLegacyKey();
2027
+ const plain = decryptWith(blob, legacy);
2028
+ writeFileSync5(getMemoryPath(), encryptWith(plain, key), "utf8");
2029
+ return plain;
2030
+ }
2031
+ }
2032
+ function loadStore() {
2033
+ const path = getMemoryPath();
2034
+ if (!existsSync6(path)) {
2035
+ return { entries: {} };
2036
+ }
2037
+ try {
2038
+ const raw = readFileSync7(path, "utf8");
2039
+ const decrypted = decrypt(raw);
2040
+ return JSON.parse(decrypted);
2041
+ } catch {
2042
+ return { entries: {} };
2043
+ }
2044
+ }
2045
+ function saveStore(store) {
2046
+ const json = JSON.stringify(store);
2047
+ const encrypted = encrypt(json);
2048
+ writeFileSync5(getMemoryPath(), encrypted, "utf8");
2049
+ }
2050
+ function remember(key, value) {
2051
+ const store = loadStore();
2052
+ store.entries[key] = {
2053
+ value,
2054
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2055
+ };
2056
+ saveStore(store);
2057
+ }
2058
+ function recall(key) {
2059
+ const store = loadStore();
2060
+ return store.entries[key]?.value ?? null;
2061
+ }
2062
+ function listMemory() {
2063
+ const store = loadStore();
2064
+ return Object.entries(store.entries).map(([key, entry]) => ({
2065
+ key,
2066
+ updatedAt: entry.updatedAt
2067
+ }));
2068
+ }
2069
+ function forget(key) {
2070
+ const store = loadStore();
2071
+ if (key in store.entries) {
2072
+ delete store.entries[key];
2073
+ saveStore(store);
2074
+ return true;
2075
+ }
2076
+ return false;
2077
+ }
2078
+ function clearMemory() {
2079
+ saveStore({ entries: {} });
2080
+ }
2081
+
1642
2082
  export {
2083
+ PACKAGE_VERSION,
1643
2084
  checkDecay,
1644
2085
  readProjectConfig,
1645
2086
  collapseEnvironment,
@@ -1649,7 +2090,7 @@ export {
1649
2090
  exportAudit,
1650
2091
  detectAnomalies,
1651
2092
  listEntanglements,
1652
- httpRequest_,
2093
+ httpRequest,
1653
2094
  checkSSRF,
1654
2095
  registerHook,
1655
2096
  removeHook,
@@ -1676,6 +2117,11 @@ export {
1676
2117
  tunnelCreate,
1677
2118
  tunnelRead,
1678
2119
  tunnelDestroy,
1679
- tunnelList
2120
+ tunnelList,
2121
+ remember,
2122
+ recall,
2123
+ listMemory,
2124
+ forget,
2125
+ clearMemory
1680
2126
  };
1681
- //# sourceMappingURL=chunk-JV4JFWV6.js.map
2127
+ //# sourceMappingURL=chunk-A4RZVP3P.js.map