@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();
@@ -721,6 +838,14 @@ function matchesHook(hook, payload, tags) {
721
838
  if (m.scope && m.scope !== payload.scope) return false;
722
839
  return true;
723
840
  }
841
+ var SAFE_PGREP_NAME = /^[a-zA-Z0-9._:/-]{1,256}$/;
842
+ function shellRunner() {
843
+ if (process.platform === "win32") {
844
+ const comspec = process.env.ComSpec ?? "cmd.exe";
845
+ return { file: comspec, args: ["/d", "/s", "/c"] };
846
+ }
847
+ return { file: "/bin/sh", args: ["-c"] };
848
+ }
724
849
  function executeShell(command, payload) {
725
850
  return new Promise((resolve) => {
726
851
  const env = {
@@ -729,13 +854,33 @@ function executeShell(command, payload) {
729
854
  QRING_HOOK_ACTION: payload.action,
730
855
  QRING_HOOK_SCOPE: payload.scope
731
856
  };
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" });
857
+ const { file, args } = shellRunner();
858
+ execFile(
859
+ file,
860
+ [...args, command],
861
+ {
862
+ timeout: 3e4,
863
+ env,
864
+ windowsHide: true,
865
+ maxBuffer: 4 * 1024 * 1024,
866
+ encoding: "utf8"
867
+ },
868
+ (err, stdout) => {
869
+ if (err) {
870
+ resolve({
871
+ hookId: "",
872
+ success: false,
873
+ message: `Shell error: ${err.message}`
874
+ });
875
+ } else {
876
+ resolve({
877
+ hookId: "",
878
+ success: true,
879
+ message: (stdout ?? "").trim() || "OK"
880
+ });
881
+ }
737
882
  }
738
- });
883
+ );
739
884
  });
740
885
  }
741
886
  async function executeHttp(url, payload) {
@@ -752,7 +897,7 @@ async function executeHttp(url, payload) {
752
897
  }
753
898
  try {
754
899
  const body = JSON.stringify(payload);
755
- const res = await httpRequest_({
900
+ const res = await httpRequest({
756
901
  url,
757
902
  method: "POST",
758
903
  headers: {
@@ -777,24 +922,48 @@ async function executeHttp(url, payload) {
777
922
  }
778
923
  function executeSignal(target, signal = "SIGHUP") {
779
924
  return new Promise((resolve) => {
780
- const pid = parseInt(target, 10);
781
- if (!isNaN(pid)) {
925
+ const trimmed = target.trim();
926
+ if (/^\d+$/.test(trimmed)) {
927
+ const pid = parseInt(trimmed, 10);
782
928
  try {
783
929
  process.kill(pid, signal);
784
- resolve({ hookId: "", success: true, message: `Signal ${signal} sent to PID ${pid}` });
930
+ resolve({
931
+ hookId: "",
932
+ success: true,
933
+ message: `Signal ${signal} sent to PID ${pid}`
934
+ });
785
935
  } catch (err) {
786
- resolve({ hookId: "", success: false, message: `Signal error: ${err instanceof Error ? err.message : String(err)}` });
936
+ resolve({
937
+ hookId: "",
938
+ success: false,
939
+ message: `Signal error: ${err instanceof Error ? err.message : String(err)}`
940
+ });
787
941
  }
788
942
  return;
789
943
  }
790
- const child = spawn("pgrep", ["-f", target], { timeout: 5e3, stdio: ["ignore", "pipe", "pipe"] });
944
+ if (!SAFE_PGREP_NAME.test(trimmed)) {
945
+ resolve({
946
+ hookId: "",
947
+ success: false,
948
+ message: "Signal target must be a numeric PID, or a process name matching /^[a-zA-Z0-9._:/-]{1,256}$/ (no spaces or shell metacharacters)."
949
+ });
950
+ return;
951
+ }
952
+ const child = spawn("pgrep", ["-f", trimmed], {
953
+ timeout: 5e3,
954
+ stdio: ["ignore", "pipe", "pipe"]
955
+ });
791
956
  let stdout = "";
792
957
  child.stdout.on("data", (d) => {
793
958
  stdout += d.toString();
794
959
  });
795
960
  child.on("close", (code) => {
796
961
  if (code !== 0 || !stdout.trim()) {
797
- resolve({ hookId: "", success: false, message: `Process "${target}" not found` });
962
+ resolve({
963
+ hookId: "",
964
+ success: false,
965
+ message: `Process "${trimmed}" not found`
966
+ });
798
967
  return;
799
968
  }
800
969
  const pids = stdout.trim().split("\n").map((p) => parseInt(p.trim(), 10)).filter((p) => !isNaN(p));
@@ -806,10 +975,18 @@ function executeSignal(target, signal = "SIGHUP") {
806
975
  } catch {
807
976
  }
808
977
  }
809
- resolve({ hookId: "", success: sent > 0, message: `Signal ${signal} sent to ${sent} process(es)` });
978
+ resolve({
979
+ hookId: "",
980
+ success: sent > 0,
981
+ message: `Signal ${signal} sent to ${sent} process(es)`
982
+ });
810
983
  });
811
984
  child.on("error", () => {
812
- resolve({ hookId: "", success: false, message: `Process "${target}" not found` });
985
+ resolve({
986
+ hookId: "",
987
+ success: false,
988
+ message: `Process "${trimmed}" not found`
989
+ });
813
990
  });
814
991
  });
815
992
  }
@@ -866,33 +1043,50 @@ async function fireHooks(payload, tags) {
866
1043
  }
867
1044
 
868
1045
  // src/core/approval.ts
869
- import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
1046
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
870
1047
  import { join as join5 } from "path";
871
1048
  import { homedir as homedir4 } from "os";
872
- import { createHmac, randomBytes } from "crypto";
1049
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
873
1050
  function getHmacSecret() {
874
- const secretPath = join5(homedir4(), ".config", "q-ring", ".approval-key");
875
1051
  const dir = join5(homedir4(), ".config", "q-ring");
876
- if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true });
1052
+ const secretPath = join5(dir, ".approval-key");
1053
+ if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
877
1054
  if (existsSync5(secretPath)) {
878
1055
  return readFileSync5(secretPath, "utf8").trim();
879
1056
  }
880
1057
  const secret = randomBytes(32).toString("hex");
881
- writeFileSync4(secretPath, secret, { mode: 384 });
1058
+ writeFileSync3(secretPath, secret, { mode: 384 });
882
1059
  return secret;
883
1060
  }
884
1061
  function computeHmac(entry) {
885
- const payload = `${entry.id}|${entry.key}|${entry.scope}|${entry.reason}|${entry.grantedBy}|${entry.grantedAt}|${entry.expiresAt}`;
1062
+ const payload = [
1063
+ entry.id,
1064
+ entry.key,
1065
+ entry.scope,
1066
+ entry.reason,
1067
+ entry.grantedBy,
1068
+ entry.grantedAt,
1069
+ entry.expiresAt,
1070
+ entry.workspace ?? "",
1071
+ entry.sessionId ?? ""
1072
+ ].join("|");
886
1073
  return createHmac("sha256", getHmacSecret()).update(payload).digest("hex");
887
1074
  }
888
1075
  function verifyHmac(entry) {
889
1076
  const expected = computeHmac(entry);
890
- return expected === entry.hmac;
1077
+ const a = Buffer.from(expected, "utf8");
1078
+ const b = Buffer.from(entry.hmac, "utf8");
1079
+ if (a.length !== b.length) return false;
1080
+ try {
1081
+ return timingSafeEqual(a, b);
1082
+ } catch {
1083
+ return false;
1084
+ }
891
1085
  }
892
1086
  function getRegistryPath3() {
893
1087
  const dir = join5(homedir4(), ".config", "q-ring");
894
1088
  if (!existsSync5(dir)) {
895
- mkdirSync4(dir, { recursive: true });
1089
+ mkdirSync4(dir, { recursive: true, mode: 448 });
896
1090
  }
897
1091
  return join5(dir, "approvals.json");
898
1092
  }
@@ -908,7 +1102,7 @@ function loadRegistry3() {
908
1102
  }
909
1103
  }
910
1104
  function saveRegistry3(registry2) {
911
- writeFileSync4(getRegistryPath3(), JSON.stringify(registry2, null, 2), { mode: 384 });
1105
+ writeFileSync3(getRegistryPath3(), JSON.stringify(registry2, null, 2), { mode: 384 });
912
1106
  }
913
1107
  function cleanup(registry2) {
914
1108
  const now = Date.now();
@@ -976,6 +1170,20 @@ function listApprovals() {
976
1170
 
977
1171
  // src/core/provision.ts
978
1172
  import { execFileSync, spawnSync } from "child_process";
1173
+ import { z as z2 } from "zod";
1174
+ var AwsStsConfigSchema = z2.object({
1175
+ roleArn: z2.string(),
1176
+ sessionName: z2.string().optional(),
1177
+ durationSeconds: z2.number().optional()
1178
+ });
1179
+ var HttpJitConfigSchema = z2.object({
1180
+ url: z2.string(),
1181
+ method: z2.string().optional(),
1182
+ valuePath: z2.string().optional(),
1183
+ expiresInSeconds: z2.number().optional(),
1184
+ headers: z2.record(z2.string(), z2.string()).optional(),
1185
+ body: z2.unknown().optional()
1186
+ });
979
1187
  var ProvisionRegistry = class {
980
1188
  providers = /* @__PURE__ */ new Map();
981
1189
  register(provider) {
@@ -992,16 +1200,20 @@ var awsStsProvider = {
992
1200
  name: "aws-sts",
993
1201
  description: "AWS STS AssumeRole (requires existing local AWS CLI credentials)",
994
1202
  provision(configRaw) {
995
- let config;
1203
+ let raw;
996
1204
  try {
997
- config = JSON.parse(configRaw);
1205
+ raw = JSON.parse(configRaw);
998
1206
  } catch {
999
1207
  throw new Error('aws-sts requires valid JSON config (e.g. {"roleArn":"arn:aws:..."})');
1000
1208
  }
1209
+ const parsed = AwsStsConfigSchema.safeParse(raw);
1210
+ if (!parsed.success) {
1211
+ throw new Error(`aws-sts invalid config: ${parsed.error.message}`);
1212
+ }
1213
+ const config = parsed.data;
1001
1214
  const roleArn = config.roleArn;
1002
1215
  const sessionName = config.sessionName || "q-ring-agent";
1003
1216
  const duration = config.durationSeconds || 3600;
1004
- if (!roleArn) throw new Error("aws-sts requires roleArn in config");
1005
1217
  try {
1006
1218
  const output = execFileSync("aws", [
1007
1219
  "sts",
@@ -1015,8 +1227,8 @@ var awsStsProvider = {
1015
1227
  "--output",
1016
1228
  "json"
1017
1229
  ], { encoding: "utf8" });
1018
- const parsed = JSON.parse(output);
1019
- const creds = parsed.Credentials;
1230
+ const parsed2 = JSON.parse(output);
1231
+ const creds = parsed2.Credentials;
1020
1232
  const value = JSON.stringify({
1021
1233
  AWS_ACCESS_KEY_ID: creds.AccessKeyId,
1022
1234
  AWS_SECRET_ACCESS_KEY: creds.SecretAccessKey,
@@ -1035,18 +1247,23 @@ var httpProvider = {
1035
1247
  name: "http",
1036
1248
  description: "Generic HTTP token endpoint using Node.js http/https",
1037
1249
  provision(configRaw) {
1038
- let config;
1250
+ let raw;
1039
1251
  try {
1040
- config = JSON.parse(configRaw);
1252
+ raw = JSON.parse(configRaw);
1041
1253
  } catch {
1042
1254
  throw new Error("http provider requires valid JSON config");
1043
1255
  }
1256
+ const parsed = HttpJitConfigSchema.safeParse(raw);
1257
+ if (!parsed.success) {
1258
+ throw new Error(`http provider invalid config: ${parsed.error.message}`);
1259
+ }
1260
+ const config = parsed.data;
1044
1261
  const url = config.url;
1045
1262
  const method = config.method || "POST";
1046
1263
  const valuePath = config.valuePath || "token";
1047
1264
  const expiresInSeconds = config.expiresInSeconds || 3600;
1048
1265
  if (!url) throw new Error("http provider requires url in config");
1049
- const ssrfBlock = checkSSRFSync(url);
1266
+ const ssrfBlock = checkJitHttpProvisionUrl(url);
1050
1267
  if (ssrfBlock) throw new Error(`SSRF blocked: ${ssrfBlock}`);
1051
1268
  const headers = {
1052
1269
  "User-Agent": "q-ring-jit/1.0",
@@ -1080,8 +1297,8 @@ req.end();
1080
1297
  if (result.status !== 0) {
1081
1298
  throw new Error(result.stderr || "HTTP request failed");
1082
1299
  }
1083
- const parsed = JSON.parse(result.stdout);
1084
- let val = parsed;
1300
+ const parsed2 = JSON.parse(result.stdout);
1301
+ let val = parsed2;
1085
1302
  for (const key of valuePath.split(".")) {
1086
1303
  val = val[key];
1087
1304
  }
@@ -1110,6 +1327,39 @@ function loadPolicy(projectPath) {
1110
1327
  cachedPolicy = { path: pp, policy };
1111
1328
  return policy;
1112
1329
  }
1330
+ function checkSecretLifecyclePolicy(input, projectPath) {
1331
+ const policy = loadPolicy(projectPath);
1332
+ if (!policy.secrets) return { allowed: true, policySource: "no-policy" };
1333
+ const s = policy.secrets;
1334
+ if (s.maxTtlSeconds != null && input.ttlSeconds != null && input.ttlSeconds > s.maxTtlSeconds) {
1335
+ return {
1336
+ allowed: false,
1337
+ reason: `TTL ${input.ttlSeconds}s exceeds policy maximum ${s.maxTtlSeconds}s`,
1338
+ policySource: ".q-ring.json policy.secrets.maxTtlSeconds"
1339
+ };
1340
+ }
1341
+ if (s.requireApprovalForTags?.length && input.tags?.length) {
1342
+ const hit = input.tags.find((t) => s.requireApprovalForTags.includes(t));
1343
+ if (hit && !input.requiresApproval) {
1344
+ return {
1345
+ allowed: false,
1346
+ reason: `Tag "${hit}" requires explicit approval metadata (set requiresApproval / --requires-approval)`,
1347
+ policySource: ".q-ring.json policy.secrets.requireApprovalForTags"
1348
+ };
1349
+ }
1350
+ }
1351
+ if (s.requireRotationFormatForTags?.length && input.tags?.length) {
1352
+ const hit = input.tags.find((t) => s.requireRotationFormatForTags.includes(t));
1353
+ if (hit && !input.rotationFormat) {
1354
+ return {
1355
+ allowed: false,
1356
+ reason: `Tag "${hit}" requires a rotationFormat to be set`,
1357
+ policySource: ".q-ring.json policy.secrets.requireRotationFormatForTags"
1358
+ };
1359
+ }
1360
+ }
1361
+ return { allowed: true, policySource: ".q-ring.json" };
1362
+ }
1113
1363
  function checkKeyReadPolicy(key, tags, projectPath) {
1114
1364
  const policy = loadPolicy(projectPath);
1115
1365
  if (!policy.mcp) return { allowed: true, policySource: "no-policy" };
@@ -1178,6 +1428,32 @@ function getPolicySummary(projectPath) {
1178
1428
  }
1179
1429
 
1180
1430
  // src/core/keyring.ts
1431
+ function withJitEnvelopeLock(service, key, fn) {
1432
+ const dir = join6(homedir5(), ".config", "q-ring", "jit-locks");
1433
+ mkdirSync5(dir, { recursive: true });
1434
+ const safe = Buffer.from(`${service}\0${key}`, "utf8").toString("base64url");
1435
+ const lockPath = join6(dir, `${safe}.lock`);
1436
+ const deadline = Date.now() + 8e3;
1437
+ while (Date.now() < deadline) {
1438
+ try {
1439
+ writeFileSync4(lockPath, `${process.pid}
1440
+ `, { flag: "wx", mode: 384 });
1441
+ try {
1442
+ return fn();
1443
+ } finally {
1444
+ try {
1445
+ unlinkSync(lockPath);
1446
+ } catch {
1447
+ }
1448
+ }
1449
+ } catch {
1450
+ const start = Date.now();
1451
+ while (Date.now() - start < 15) {
1452
+ }
1453
+ }
1454
+ }
1455
+ throw new Error("Could not acquire JIT envelope lock (timeout)");
1456
+ }
1181
1457
  function readEnvelope(service, key) {
1182
1458
  const entry = new Entry(service, key);
1183
1459
  const raw = entry.getPassword();
@@ -1196,7 +1472,7 @@ function resolveEnv(opts) {
1196
1472
  }
1197
1473
  function resolveTemplates(value, opts, seen) {
1198
1474
  if (!value.includes("{{") || !value.includes("}}")) return value;
1199
- return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1475
+ return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
1200
1476
  const refKey = refKeyRaw.trim();
1201
1477
  const refValue = getSecret(refKey, { ...opts, _seen: seen });
1202
1478
  if (refValue === null) {
@@ -1207,7 +1483,7 @@ function resolveTemplates(value, opts, seen) {
1207
1483
  }
1208
1484
  function resolveTemplatesOffline(value, rawValues, seen) {
1209
1485
  if (!value.includes("{{") || !value.includes("}}")) return value;
1210
- return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1486
+ return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
1211
1487
  const refKey = refKeyRaw.trim();
1212
1488
  if (seen.has(refKey)) {
1213
1489
  throw new Error(`Circular dependency detected: ${[...seen].join(" -> ")} -> ${refKey}`);
@@ -1277,11 +1553,27 @@ function getSecret(key, opts = {}) {
1277
1553
  isExpired = new Date(envelope.meta.jitExpiresAt).getTime() <= Date.now();
1278
1554
  }
1279
1555
  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);
1556
+ const jitConfigSource = value;
1557
+ withJitEnvelopeLock(service, key, () => {
1558
+ const latest = readEnvelope(service, key);
1559
+ if (!latest?.meta.jitProvider) return;
1560
+ let innerExpired = true;
1561
+ if (latest.states && latest.states["jit"] && latest.meta.jitExpiresAt) {
1562
+ innerExpired = new Date(latest.meta.jitExpiresAt).getTime() <= Date.now();
1563
+ }
1564
+ if (!innerExpired) {
1565
+ envelope.states = latest.states;
1566
+ envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
1567
+ return;
1568
+ }
1569
+ const result = provider.provision(jitConfigSource);
1570
+ latest.states = latest.states ?? {};
1571
+ latest.states["jit"] = result.value;
1572
+ latest.meta.jitExpiresAt = result.expiresAt;
1573
+ writeEnvelope(service, key, latest);
1574
+ envelope.states = latest.states;
1575
+ envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
1576
+ });
1285
1577
  }
1286
1578
  value = envelope.states["jit"];
1287
1579
  }
@@ -1323,6 +1615,20 @@ function setSecret(key, value, opts = {}) {
1323
1615
  const prov = opts.provider ?? existing?.meta.provider;
1324
1616
  const reqApp = opts.requiresApproval ?? existing?.meta.requiresApproval;
1325
1617
  const jitProv = opts.jitProvider ?? existing?.meta.jitProvider;
1618
+ const mergedTags = opts.tags ?? existing?.meta.tags;
1619
+ const ttlForPolicy = opts.ttlSeconds ?? existing?.meta.ttlSeconds;
1620
+ const life = checkSecretLifecyclePolicy(
1621
+ {
1622
+ tags: mergedTags,
1623
+ ttlSeconds: ttlForPolicy,
1624
+ rotationFormat: rotFmt,
1625
+ requiresApproval: !!reqApp
1626
+ },
1627
+ opts.projectPath
1628
+ );
1629
+ if (!life.allowed) {
1630
+ throw new Error(life.reason ?? "Secret lifecycle policy denied");
1631
+ }
1326
1632
  if (opts.states) {
1327
1633
  envelope = createEnvelope("", {
1328
1634
  states: opts.states,
@@ -1649,7 +1955,7 @@ export {
1649
1955
  exportAudit,
1650
1956
  detectAnomalies,
1651
1957
  listEntanglements,
1652
- httpRequest_,
1958
+ httpRequest,
1653
1959
  checkSSRF,
1654
1960
  registerHook,
1655
1961
  removeHook,
@@ -1678,4 +1984,4 @@ export {
1678
1984
  tunnelDestroy,
1679
1985
  tunnelList
1680
1986
  };
1681
- //# sourceMappingURL=chunk-JV4JFWV6.js.map
1987
+ //# sourceMappingURL=chunk-LLMO64FE.js.map