@i4ctime/q-ring 0.9.8 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/core/envelope.ts
4
+ import { z } from "zod";
5
+ var EntanglementLinkSchema = z.object({
6
+ service: z.string(),
7
+ key: z.string()
8
+ });
9
+ var SecretMetadataSchema = z.object({
10
+ createdAt: z.string(),
11
+ updatedAt: z.string(),
12
+ expiresAt: z.string().optional(),
13
+ ttlSeconds: z.number().optional(),
14
+ description: z.string().optional(),
15
+ tags: z.array(z.string()).optional(),
16
+ entangled: z.array(EntanglementLinkSchema).optional(),
17
+ accessCount: z.number(),
18
+ lastAccessedAt: z.string().optional(),
19
+ ephemeral: z.boolean().optional(),
20
+ rotationFormat: z.string().optional(),
21
+ rotationPrefix: z.string().optional(),
22
+ provider: z.string().optional(),
23
+ validationUrl: z.string().optional(),
24
+ requiresApproval: z.boolean().optional(),
25
+ jitProvider: z.string().optional(),
26
+ jitExpiresAt: z.string().optional()
27
+ });
28
+ var QuantumEnvelopeSchema = z.object({
29
+ v: z.literal(1),
30
+ value: z.string().optional(),
31
+ states: z.record(z.string(), z.string()).optional(),
32
+ defaultEnv: z.string().optional(),
33
+ meta: SecretMetadataSchema
34
+ });
4
35
  function createEnvelope(value, opts) {
5
36
  const now = (/* @__PURE__ */ new Date()).toISOString();
6
37
  let expiresAt = opts?.expiresAt;
@@ -32,8 +63,9 @@ function createEnvelope(value, opts) {
32
63
  function parseEnvelope(raw) {
33
64
  try {
34
65
  const parsed = JSON.parse(raw);
35
- if (parsed && typeof parsed === "object" && parsed.v === 1) {
36
- return parsed;
66
+ const r = QuantumEnvelopeSchema.safeParse(parsed);
67
+ if (r.success) {
68
+ return r.data;
37
69
  }
38
70
  } catch {
39
71
  }
@@ -84,6 +116,15 @@ function checkDecay(envelope) {
84
116
  const now = Date.now();
85
117
  const expires = new Date(envelope.meta.expiresAt).getTime();
86
118
  const created = new Date(envelope.meta.createdAt).getTime();
119
+ if (!Number.isFinite(expires) || !Number.isFinite(created)) {
120
+ return {
121
+ isExpired: true,
122
+ isStale: true,
123
+ lifetimePercent: 100,
124
+ secondsRemaining: null,
125
+ timeRemaining: "invalid date"
126
+ };
127
+ }
87
128
  const totalLifetime = expires - created;
88
129
  const elapsed = now - created;
89
130
  const remaining = expires - now;
@@ -188,12 +229,15 @@ function collapseEnvironment(ctx = {}) {
188
229
  }
189
230
  return null;
190
231
  }
232
+ var MAX_BRANCH_GLOB_PATTERN_LEN = 200;
233
+ var MAX_BRANCH_GLOB_REGEX_SOURCE = 400;
191
234
  function matchGlob(branchMap, branch) {
192
235
  for (const [pattern, env] of Object.entries(branchMap)) {
193
236
  if (!pattern.includes("*")) continue;
194
- const regex = new RegExp(
195
- "^" + pattern.replace(/\*/g, ".*") + "$"
196
- );
237
+ if (pattern.length > MAX_BRANCH_GLOB_PATTERN_LEN) continue;
238
+ const source = "^" + pattern.replace(/\*/g, ".*") + "$";
239
+ if (source.length > MAX_BRANCH_GLOB_REGEX_SOURCE) continue;
240
+ const regex = new RegExp(source);
197
241
  if (regex.test(branch)) return env;
198
242
  }
199
243
  return void 0;
@@ -206,11 +250,27 @@ function mapEnvName(raw) {
206
250
  }
207
251
 
208
252
  // src/core/observer.ts
209
- import { existsSync as existsSync2, mkdirSync, appendFileSync, readFileSync as readFileSync2, openSync, fstatSync, readSync, closeSync } from "fs";
253
+ import {
254
+ existsSync as existsSync2,
255
+ mkdirSync,
256
+ appendFileSync,
257
+ readFileSync as readFileSync2,
258
+ openSync,
259
+ fstatSync,
260
+ readSync,
261
+ closeSync,
262
+ statSync
263
+ } from "fs";
210
264
  import { join as join2 } from "path";
211
265
  import { homedir } from "os";
212
266
  import { createHash } from "crypto";
213
267
  function getAuditDir() {
268
+ if (process.env.QRING_AUDIT_DIR) {
269
+ if (!existsSync2(process.env.QRING_AUDIT_DIR)) {
270
+ mkdirSync(process.env.QRING_AUDIT_DIR, { recursive: true });
271
+ }
272
+ return process.env.QRING_AUDIT_DIR;
273
+ }
214
274
  const dir = join2(homedir(), ".config", "q-ring");
215
275
  if (!existsSync2(dir)) {
216
276
  mkdirSync(dir, { recursive: true });
@@ -256,11 +316,24 @@ function logAudit(event) {
256
316
  } catch {
257
317
  }
258
318
  }
319
+ var MAX_AUDIT_BYTES = 12 * 1024 * 1024;
259
320
  function queryAudit(query = {}) {
260
321
  const path = getAuditPath();
261
322
  if (!existsSync2(path)) return [];
262
323
  try {
263
- const lines = readFileSync2(path, "utf8").split("\n").filter((l) => l.trim());
324
+ const st = statSync(path);
325
+ const readStart = st.size > MAX_AUDIT_BYTES ? st.size - MAX_AUDIT_BYTES : 0;
326
+ const readLen = st.size > MAX_AUDIT_BYTES ? MAX_AUDIT_BYTES : st.size;
327
+ const buf = Buffer.alloc(readLen);
328
+ const fd = openSync(path, "r");
329
+ readSync(fd, buf, 0, readLen, readStart);
330
+ closeSync(fd);
331
+ let text = buf.toString("utf8");
332
+ if (readStart > 0) {
333
+ const firstNl = text.indexOf("\n");
334
+ if (firstNl !== -1) text = text.slice(firstNl + 1);
335
+ }
336
+ const lines = text.split("\n").filter((l) => l.trim());
264
337
  let events = lines.map((line) => {
265
338
  try {
266
339
  return JSON.parse(line);
@@ -393,7 +466,7 @@ function detectAnomalies(key) {
393
466
  }
394
467
 
395
468
  // src/core/entanglement.ts
396
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
469
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
397
470
  import { join as join3 } from "path";
398
471
  import { homedir as homedir2 } from "os";
399
472
  function getRegistryPath() {
@@ -415,7 +488,9 @@ function loadRegistry() {
415
488
  }
416
489
  }
417
490
  function saveRegistry(registry2) {
418
- writeFileSync2(getRegistryPath(), JSON.stringify(registry2, null, 2));
491
+ writeFileSync(getRegistryPath(), JSON.stringify(registry2, null, 2), {
492
+ mode: 384
493
+ });
419
494
  }
420
495
  function entangle(source, target) {
421
496
  const registry2 = loadRegistry();
@@ -454,6 +529,9 @@ function listEntanglements() {
454
529
  }
455
530
 
456
531
  // src/core/keyring.ts
532
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, unlinkSync } from "fs";
533
+ import { homedir as homedir5 } from "os";
534
+ import { join as join6 } from "path";
457
535
  import { Entry, findCredentials } from "@napi-rs/keyring";
458
536
 
459
537
  // src/utils/hash.ts
@@ -509,18 +587,18 @@ function resolveScope(opts) {
509
587
  }
510
588
 
511
589
  // src/core/hooks.ts
512
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
590
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
513
591
  import { join as join4 } from "path";
514
592
  import { homedir as homedir3 } from "os";
515
- import { exec, spawn } from "child_process";
593
+ import { execFile, spawn } from "child_process";
516
594
  import { randomUUID } from "crypto";
517
595
 
518
596
  // src/utils/http-request.ts
519
597
  import { request as httpsRequest } from "https";
520
- import { request as httpRequest } from "http";
598
+ import { request as httpRequestPlain } from "http";
521
599
  var DEFAULT_TIMEOUT_MS = 1e4;
522
600
  var DEFAULT_MAX_RESPONSE_BYTES = 65536;
523
- function httpRequest_(opts) {
601
+ function httpRequest(opts) {
524
602
  const {
525
603
  url,
526
604
  method = "GET",
@@ -535,7 +613,7 @@ function httpRequest_(opts) {
535
613
  reject(new Error(`Unsupported URL protocol: ${parsed.protocol}`));
536
614
  return;
537
615
  }
538
- const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequest;
616
+ const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequestPlain;
539
617
  const reqHeaders = { ...headers };
540
618
  if (body && !reqHeaders["Content-Length"]) {
541
619
  reqHeaders["Content-Length"] = Buffer.byteLength(body);
@@ -598,6 +676,19 @@ function httpRequest_(opts) {
598
676
 
599
677
  // src/core/ssrf.ts
600
678
  import { lookup } from "dns/promises";
679
+ import * as dns from "dns";
680
+ import { isIPv4, isIPv6 } from "net";
681
+ function lookupAddressesSync(hostname) {
682
+ const lookupSync2 = dns.lookupSync;
683
+ return lookupSync2(hostname, { all: true });
684
+ }
685
+ function isHostnameIpLiteral(hostname) {
686
+ if (isIPv4(hostname)) return true;
687
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
688
+ return isIPv6(hostname.slice(1, -1));
689
+ }
690
+ return isIPv6(hostname);
691
+ }
601
692
  function isPrivateIP(ip) {
602
693
  const octet = "(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})";
603
694
  const ipv4Re = new RegExp(`^::ffff:(${octet}\\.${octet}\\.${octet}\\.${octet})$`, "i");
@@ -632,15 +723,39 @@ async function checkSSRF(url) {
632
723
  }
633
724
  return null;
634
725
  }
635
- function checkSSRFSync(url) {
726
+ function checkJitHttpProvisionUrl(url) {
636
727
  if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
637
728
  try {
638
729
  const parsed = new URL(url);
730
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
731
+ return "Blocked: JIT HTTP provider only allows http: or https: URLs.";
732
+ }
639
733
  const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
734
+ if (!hostname) {
735
+ return "Blocked: empty hostname in JIT URL.";
736
+ }
640
737
  if (isPrivateIP(hostname)) {
641
738
  return `Blocked: URL resolves to private address (${hostname}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
642
739
  }
740
+ if (isHostnameIpLiteral(hostname)) {
741
+ return null;
742
+ }
743
+ let results;
744
+ try {
745
+ results = lookupAddressesSync(hostname);
746
+ } catch {
747
+ return `Blocked: DNS resolution failed for "${hostname}" (JIT HTTP provisioning fails closed).`;
748
+ }
749
+ if (!results.length) {
750
+ return `Blocked: DNS returned no addresses for "${hostname}".`;
751
+ }
752
+ for (const { address } of results) {
753
+ if (isPrivateIP(address)) {
754
+ return `Blocked: URL "${hostname}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
755
+ }
756
+ }
643
757
  } catch {
758
+ return "Blocked: malformed JIT HTTP URL.";
644
759
  }
645
760
  return null;
646
761
  }
@@ -665,7 +780,9 @@ function loadRegistry2() {
665
780
  }
666
781
  }
667
782
  function saveRegistry2(registry2) {
668
- writeFileSync3(getRegistryPath2(), JSON.stringify(registry2, null, 2));
783
+ writeFileSync2(getRegistryPath2(), JSON.stringify(registry2, null, 2), {
784
+ mode: 384
785
+ });
669
786
  }
670
787
  function registerHook(entry) {
671
788
  const registry2 = loadRegistry2();
@@ -705,6 +822,14 @@ function matchesHook(hook, payload, tags) {
705
822
  if (m.scope && m.scope !== payload.scope) return false;
706
823
  return true;
707
824
  }
825
+ var SAFE_PGREP_NAME = /^[a-zA-Z0-9._:/-]{1,256}$/;
826
+ function shellRunner() {
827
+ if (process.platform === "win32") {
828
+ const comspec = process.env.ComSpec ?? "cmd.exe";
829
+ return { file: comspec, args: ["/d", "/s", "/c"] };
830
+ }
831
+ return { file: "/bin/sh", args: ["-c"] };
832
+ }
708
833
  function executeShell(command, payload) {
709
834
  return new Promise((resolve) => {
710
835
  const env = {
@@ -713,13 +838,33 @@ function executeShell(command, payload) {
713
838
  QRING_HOOK_ACTION: payload.action,
714
839
  QRING_HOOK_SCOPE: payload.scope
715
840
  };
716
- exec(command, { timeout: 3e4, env }, (err, stdout, stderr) => {
717
- if (err) {
718
- resolve({ hookId: "", success: false, message: `Shell error: ${err.message}` });
719
- } else {
720
- resolve({ hookId: "", success: true, message: stdout.trim() || "OK" });
841
+ const { file, args } = shellRunner();
842
+ execFile(
843
+ file,
844
+ [...args, command],
845
+ {
846
+ timeout: 3e4,
847
+ env,
848
+ windowsHide: true,
849
+ maxBuffer: 4 * 1024 * 1024,
850
+ encoding: "utf8"
851
+ },
852
+ (err, stdout) => {
853
+ if (err) {
854
+ resolve({
855
+ hookId: "",
856
+ success: false,
857
+ message: `Shell error: ${err.message}`
858
+ });
859
+ } else {
860
+ resolve({
861
+ hookId: "",
862
+ success: true,
863
+ message: (stdout ?? "").trim() || "OK"
864
+ });
865
+ }
721
866
  }
722
- });
867
+ );
723
868
  });
724
869
  }
725
870
  async function executeHttp(url, payload) {
@@ -736,7 +881,7 @@ async function executeHttp(url, payload) {
736
881
  }
737
882
  try {
738
883
  const body = JSON.stringify(payload);
739
- const res = await httpRequest_({
884
+ const res = await httpRequest({
740
885
  url,
741
886
  method: "POST",
742
887
  headers: {
@@ -761,24 +906,48 @@ async function executeHttp(url, payload) {
761
906
  }
762
907
  function executeSignal(target, signal = "SIGHUP") {
763
908
  return new Promise((resolve) => {
764
- const pid = parseInt(target, 10);
765
- if (!isNaN(pid)) {
909
+ const trimmed = target.trim();
910
+ if (/^\d+$/.test(trimmed)) {
911
+ const pid = parseInt(trimmed, 10);
766
912
  try {
767
913
  process.kill(pid, signal);
768
- resolve({ hookId: "", success: true, message: `Signal ${signal} sent to PID ${pid}` });
914
+ resolve({
915
+ hookId: "",
916
+ success: true,
917
+ message: `Signal ${signal} sent to PID ${pid}`
918
+ });
769
919
  } catch (err) {
770
- resolve({ hookId: "", success: false, message: `Signal error: ${err instanceof Error ? err.message : String(err)}` });
920
+ resolve({
921
+ hookId: "",
922
+ success: false,
923
+ message: `Signal error: ${err instanceof Error ? err.message : String(err)}`
924
+ });
771
925
  }
772
926
  return;
773
927
  }
774
- const child = spawn("pgrep", ["-f", target], { timeout: 5e3, stdio: ["ignore", "pipe", "pipe"] });
928
+ if (!SAFE_PGREP_NAME.test(trimmed)) {
929
+ resolve({
930
+ hookId: "",
931
+ success: false,
932
+ message: "Signal target must be a numeric PID, or a process name matching /^[a-zA-Z0-9._:/-]{1,256}$/ (no spaces or shell metacharacters)."
933
+ });
934
+ return;
935
+ }
936
+ const child = spawn("pgrep", ["-f", trimmed], {
937
+ timeout: 5e3,
938
+ stdio: ["ignore", "pipe", "pipe"]
939
+ });
775
940
  let stdout = "";
776
941
  child.stdout.on("data", (d) => {
777
942
  stdout += d.toString();
778
943
  });
779
944
  child.on("close", (code) => {
780
945
  if (code !== 0 || !stdout.trim()) {
781
- resolve({ hookId: "", success: false, message: `Process "${target}" not found` });
946
+ resolve({
947
+ hookId: "",
948
+ success: false,
949
+ message: `Process "${trimmed}" not found`
950
+ });
782
951
  return;
783
952
  }
784
953
  const pids = stdout.trim().split("\n").map((p) => parseInt(p.trim(), 10)).filter((p) => !isNaN(p));
@@ -790,10 +959,18 @@ function executeSignal(target, signal = "SIGHUP") {
790
959
  } catch {
791
960
  }
792
961
  }
793
- resolve({ hookId: "", success: sent > 0, message: `Signal ${signal} sent to ${sent} process(es)` });
962
+ resolve({
963
+ hookId: "",
964
+ success: sent > 0,
965
+ message: `Signal ${signal} sent to ${sent} process(es)`
966
+ });
794
967
  });
795
968
  child.on("error", () => {
796
- resolve({ hookId: "", success: false, message: `Process "${target}" not found` });
969
+ resolve({
970
+ hookId: "",
971
+ success: false,
972
+ message: `Process "${trimmed}" not found`
973
+ });
797
974
  });
798
975
  });
799
976
  }
@@ -850,33 +1027,50 @@ async function fireHooks(payload, tags) {
850
1027
  }
851
1028
 
852
1029
  // src/core/approval.ts
853
- import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
1030
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
854
1031
  import { join as join5 } from "path";
855
1032
  import { homedir as homedir4 } from "os";
856
- import { createHmac, randomBytes } from "crypto";
1033
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
857
1034
  function getHmacSecret() {
858
- const secretPath = join5(homedir4(), ".config", "q-ring", ".approval-key");
859
1035
  const dir = join5(homedir4(), ".config", "q-ring");
860
- if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true });
1036
+ const secretPath = join5(dir, ".approval-key");
1037
+ if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
861
1038
  if (existsSync5(secretPath)) {
862
1039
  return readFileSync5(secretPath, "utf8").trim();
863
1040
  }
864
1041
  const secret = randomBytes(32).toString("hex");
865
- writeFileSync4(secretPath, secret, { mode: 384 });
1042
+ writeFileSync3(secretPath, secret, { mode: 384 });
866
1043
  return secret;
867
1044
  }
868
1045
  function computeHmac(entry) {
869
- const payload = `${entry.id}|${entry.key}|${entry.scope}|${entry.reason}|${entry.grantedBy}|${entry.grantedAt}|${entry.expiresAt}`;
1046
+ const payload = [
1047
+ entry.id,
1048
+ entry.key,
1049
+ entry.scope,
1050
+ entry.reason,
1051
+ entry.grantedBy,
1052
+ entry.grantedAt,
1053
+ entry.expiresAt,
1054
+ entry.workspace ?? "",
1055
+ entry.sessionId ?? ""
1056
+ ].join("|");
870
1057
  return createHmac("sha256", getHmacSecret()).update(payload).digest("hex");
871
1058
  }
872
1059
  function verifyHmac(entry) {
873
1060
  const expected = computeHmac(entry);
874
- return expected === entry.hmac;
1061
+ const a = Buffer.from(expected, "utf8");
1062
+ const b = Buffer.from(entry.hmac, "utf8");
1063
+ if (a.length !== b.length) return false;
1064
+ try {
1065
+ return timingSafeEqual(a, b);
1066
+ } catch {
1067
+ return false;
1068
+ }
875
1069
  }
876
1070
  function getRegistryPath3() {
877
1071
  const dir = join5(homedir4(), ".config", "q-ring");
878
1072
  if (!existsSync5(dir)) {
879
- mkdirSync4(dir, { recursive: true });
1073
+ mkdirSync4(dir, { recursive: true, mode: 448 });
880
1074
  }
881
1075
  return join5(dir, "approvals.json");
882
1076
  }
@@ -904,6 +1098,20 @@ function hasApproval(key, scope) {
904
1098
 
905
1099
  // src/core/provision.ts
906
1100
  import { execFileSync, spawnSync } from "child_process";
1101
+ import { z as z2 } from "zod";
1102
+ var AwsStsConfigSchema = z2.object({
1103
+ roleArn: z2.string(),
1104
+ sessionName: z2.string().optional(),
1105
+ durationSeconds: z2.number().optional()
1106
+ });
1107
+ var HttpJitConfigSchema = z2.object({
1108
+ url: z2.string(),
1109
+ method: z2.string().optional(),
1110
+ valuePath: z2.string().optional(),
1111
+ expiresInSeconds: z2.number().optional(),
1112
+ headers: z2.record(z2.string(), z2.string()).optional(),
1113
+ body: z2.unknown().optional()
1114
+ });
907
1115
  var ProvisionRegistry = class {
908
1116
  providers = /* @__PURE__ */ new Map();
909
1117
  register(provider) {
@@ -920,16 +1128,20 @@ var awsStsProvider = {
920
1128
  name: "aws-sts",
921
1129
  description: "AWS STS AssumeRole (requires existing local AWS CLI credentials)",
922
1130
  provision(configRaw) {
923
- let config;
1131
+ let raw;
924
1132
  try {
925
- config = JSON.parse(configRaw);
1133
+ raw = JSON.parse(configRaw);
926
1134
  } catch {
927
1135
  throw new Error('aws-sts requires valid JSON config (e.g. {"roleArn":"arn:aws:..."})');
928
1136
  }
1137
+ const parsed = AwsStsConfigSchema.safeParse(raw);
1138
+ if (!parsed.success) {
1139
+ throw new Error(`aws-sts invalid config: ${parsed.error.message}`);
1140
+ }
1141
+ const config = parsed.data;
929
1142
  const roleArn = config.roleArn;
930
1143
  const sessionName = config.sessionName || "q-ring-agent";
931
1144
  const duration = config.durationSeconds || 3600;
932
- if (!roleArn) throw new Error("aws-sts requires roleArn in config");
933
1145
  try {
934
1146
  const output = execFileSync("aws", [
935
1147
  "sts",
@@ -943,8 +1155,8 @@ var awsStsProvider = {
943
1155
  "--output",
944
1156
  "json"
945
1157
  ], { encoding: "utf8" });
946
- const parsed = JSON.parse(output);
947
- const creds = parsed.Credentials;
1158
+ const parsed2 = JSON.parse(output);
1159
+ const creds = parsed2.Credentials;
948
1160
  const value = JSON.stringify({
949
1161
  AWS_ACCESS_KEY_ID: creds.AccessKeyId,
950
1162
  AWS_SECRET_ACCESS_KEY: creds.SecretAccessKey,
@@ -963,18 +1175,23 @@ var httpProvider = {
963
1175
  name: "http",
964
1176
  description: "Generic HTTP token endpoint using Node.js http/https",
965
1177
  provision(configRaw) {
966
- let config;
1178
+ let raw;
967
1179
  try {
968
- config = JSON.parse(configRaw);
1180
+ raw = JSON.parse(configRaw);
969
1181
  } catch {
970
1182
  throw new Error("http provider requires valid JSON config");
971
1183
  }
1184
+ const parsed = HttpJitConfigSchema.safeParse(raw);
1185
+ if (!parsed.success) {
1186
+ throw new Error(`http provider invalid config: ${parsed.error.message}`);
1187
+ }
1188
+ const config = parsed.data;
972
1189
  const url = config.url;
973
1190
  const method = config.method || "POST";
974
1191
  const valuePath = config.valuePath || "token";
975
1192
  const expiresInSeconds = config.expiresInSeconds || 3600;
976
1193
  if (!url) throw new Error("http provider requires url in config");
977
- const ssrfBlock = checkSSRFSync(url);
1194
+ const ssrfBlock = checkJitHttpProvisionUrl(url);
978
1195
  if (ssrfBlock) throw new Error(`SSRF blocked: ${ssrfBlock}`);
979
1196
  const headers = {
980
1197
  "User-Agent": "q-ring-jit/1.0",
@@ -1008,8 +1225,8 @@ req.end();
1008
1225
  if (result.status !== 0) {
1009
1226
  throw new Error(result.stderr || "HTTP request failed");
1010
1227
  }
1011
- const parsed = JSON.parse(result.stdout);
1012
- let val = parsed;
1228
+ const parsed2 = JSON.parse(result.stdout);
1229
+ let val = parsed2;
1013
1230
  for (const key of valuePath.split(".")) {
1014
1231
  val = val[key];
1015
1232
  }
@@ -1057,6 +1274,39 @@ function checkToolPolicy(toolName, projectPath) {
1057
1274
  }
1058
1275
  return { allowed: true, policySource: ".q-ring.json" };
1059
1276
  }
1277
+ function checkSecretLifecyclePolicy(input, projectPath) {
1278
+ const policy = loadPolicy(projectPath);
1279
+ if (!policy.secrets) return { allowed: true, policySource: "no-policy" };
1280
+ const s = policy.secrets;
1281
+ if (s.maxTtlSeconds != null && input.ttlSeconds != null && input.ttlSeconds > s.maxTtlSeconds) {
1282
+ return {
1283
+ allowed: false,
1284
+ reason: `TTL ${input.ttlSeconds}s exceeds policy maximum ${s.maxTtlSeconds}s`,
1285
+ policySource: ".q-ring.json policy.secrets.maxTtlSeconds"
1286
+ };
1287
+ }
1288
+ if (s.requireApprovalForTags?.length && input.tags?.length) {
1289
+ const hit = input.tags.find((t) => s.requireApprovalForTags.includes(t));
1290
+ if (hit && !input.requiresApproval) {
1291
+ return {
1292
+ allowed: false,
1293
+ reason: `Tag "${hit}" requires explicit approval metadata (set requiresApproval / --requires-approval)`,
1294
+ policySource: ".q-ring.json policy.secrets.requireApprovalForTags"
1295
+ };
1296
+ }
1297
+ }
1298
+ if (s.requireRotationFormatForTags?.length && input.tags?.length) {
1299
+ const hit = input.tags.find((t) => s.requireRotationFormatForTags.includes(t));
1300
+ if (hit && !input.rotationFormat) {
1301
+ return {
1302
+ allowed: false,
1303
+ reason: `Tag "${hit}" requires a rotationFormat to be set`,
1304
+ policySource: ".q-ring.json policy.secrets.requireRotationFormatForTags"
1305
+ };
1306
+ }
1307
+ }
1308
+ return { allowed: true, policySource: ".q-ring.json" };
1309
+ }
1060
1310
  function checkKeyReadPolicy(key, tags, projectPath) {
1061
1311
  const policy = loadPolicy(projectPath);
1062
1312
  if (!policy.mcp) return { allowed: true, policySource: "no-policy" };
@@ -1125,6 +1375,32 @@ function getPolicySummary(projectPath) {
1125
1375
  }
1126
1376
 
1127
1377
  // src/core/keyring.ts
1378
+ function withJitEnvelopeLock(service, key, fn) {
1379
+ const dir = join6(homedir5(), ".config", "q-ring", "jit-locks");
1380
+ mkdirSync5(dir, { recursive: true });
1381
+ const safe = Buffer.from(`${service}\0${key}`, "utf8").toString("base64url");
1382
+ const lockPath = join6(dir, `${safe}.lock`);
1383
+ const deadline = Date.now() + 8e3;
1384
+ while (Date.now() < deadline) {
1385
+ try {
1386
+ writeFileSync4(lockPath, `${process.pid}
1387
+ `, { flag: "wx", mode: 384 });
1388
+ try {
1389
+ return fn();
1390
+ } finally {
1391
+ try {
1392
+ unlinkSync(lockPath);
1393
+ } catch {
1394
+ }
1395
+ }
1396
+ } catch {
1397
+ const start = Date.now();
1398
+ while (Date.now() - start < 15) {
1399
+ }
1400
+ }
1401
+ }
1402
+ throw new Error("Could not acquire JIT envelope lock (timeout)");
1403
+ }
1128
1404
  function readEnvelope(service, key) {
1129
1405
  const entry = new Entry(service, key);
1130
1406
  const raw = entry.getPassword();
@@ -1143,7 +1419,7 @@ function resolveEnv(opts) {
1143
1419
  }
1144
1420
  function resolveTemplates(value, opts, seen) {
1145
1421
  if (!value.includes("{{") || !value.includes("}}")) return value;
1146
- return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1422
+ return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
1147
1423
  const refKey = refKeyRaw.trim();
1148
1424
  const refValue = getSecret(refKey, { ...opts, _seen: seen });
1149
1425
  if (refValue === null) {
@@ -1154,7 +1430,7 @@ function resolveTemplates(value, opts, seen) {
1154
1430
  }
1155
1431
  function resolveTemplatesOffline(value, rawValues, seen) {
1156
1432
  if (!value.includes("{{") || !value.includes("}}")) return value;
1157
- return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1433
+ return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
1158
1434
  const refKey = refKeyRaw.trim();
1159
1435
  if (seen.has(refKey)) {
1160
1436
  throw new Error(`Circular dependency detected: ${[...seen].join(" -> ")} -> ${refKey}`);
@@ -1224,11 +1500,27 @@ function getSecret(key, opts = {}) {
1224
1500
  isExpired = new Date(envelope.meta.jitExpiresAt).getTime() <= Date.now();
1225
1501
  }
1226
1502
  if (isExpired) {
1227
- const result = provider.provision(value);
1228
- envelope.states = envelope.states ?? {};
1229
- envelope.states["jit"] = result.value;
1230
- envelope.meta.jitExpiresAt = result.expiresAt;
1231
- writeEnvelope(service, key, envelope);
1503
+ const jitConfigSource = value;
1504
+ withJitEnvelopeLock(service, key, () => {
1505
+ const latest = readEnvelope(service, key);
1506
+ if (!latest?.meta.jitProvider) return;
1507
+ let innerExpired = true;
1508
+ if (latest.states && latest.states["jit"] && latest.meta.jitExpiresAt) {
1509
+ innerExpired = new Date(latest.meta.jitExpiresAt).getTime() <= Date.now();
1510
+ }
1511
+ if (!innerExpired) {
1512
+ envelope.states = latest.states;
1513
+ envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
1514
+ return;
1515
+ }
1516
+ const result = provider.provision(jitConfigSource);
1517
+ latest.states = latest.states ?? {};
1518
+ latest.states["jit"] = result.value;
1519
+ latest.meta.jitExpiresAt = result.expiresAt;
1520
+ writeEnvelope(service, key, latest);
1521
+ envelope.states = latest.states;
1522
+ envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
1523
+ });
1232
1524
  }
1233
1525
  value = envelope.states["jit"];
1234
1526
  }
@@ -1270,6 +1562,20 @@ function setSecret(key, value, opts = {}) {
1270
1562
  const prov = opts.provider ?? existing?.meta.provider;
1271
1563
  const reqApp = opts.requiresApproval ?? existing?.meta.requiresApproval;
1272
1564
  const jitProv = opts.jitProvider ?? existing?.meta.jitProvider;
1565
+ const mergedTags = opts.tags ?? existing?.meta.tags;
1566
+ const ttlForPolicy = opts.ttlSeconds ?? existing?.meta.ttlSeconds;
1567
+ const life = checkSecretLifecyclePolicy(
1568
+ {
1569
+ tags: mergedTags,
1570
+ ttlSeconds: ttlForPolicy,
1571
+ rotationFormat: rotFmt,
1572
+ requiresApproval: !!reqApp
1573
+ },
1574
+ opts.projectPath
1575
+ );
1576
+ if (!life.allowed) {
1577
+ throw new Error(life.reason ?? "Secret lifecycle policy denied");
1578
+ }
1273
1579
  if (opts.states) {
1274
1580
  envelope = createEnvelope("", {
1275
1581
  states: opts.states,
@@ -1596,7 +1902,7 @@ export {
1596
1902
  exportAudit,
1597
1903
  detectAnomalies,
1598
1904
  listEntanglements,
1599
- httpRequest_,
1905
+ httpRequest,
1600
1906
  checkSSRF,
1601
1907
  registerHook,
1602
1908
  removeHook,
@@ -1622,4 +1928,4 @@ export {
1622
1928
  tunnelDestroy,
1623
1929
  tunnelList
1624
1930
  };
1625
- //# sourceMappingURL=chunk-ZV7LRKBA.js.map
1931
+ //# sourceMappingURL=chunk-E46HG5NM.js.map