@i4ctime/q-ring 0.13.1 → 0.14.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.
@@ -177,7 +177,7 @@ function recordAccess(envelope) {
177
177
 
178
178
  // src/core/collapse.ts
179
179
  import { execSync } from "child_process";
180
- import { existsSync, readFileSync as readFileSync2 } from "fs";
180
+ import { readFileSync as readFileSync2, statSync } from "fs";
181
181
  import { join as join2 } from "path";
182
182
  var BRANCH_ENV_MAP = {
183
183
  main: "prod",
@@ -191,28 +191,49 @@ var BRANCH_ENV_MAP = {
191
191
  test: "test",
192
192
  testing: "test"
193
193
  };
194
+ var BRANCH_CACHE_TTL_MS = 2e3;
195
+ var branchCache = null;
194
196
  function detectGitBranch(cwd) {
197
+ const dir = cwd ?? process.cwd();
198
+ const now = Date.now();
199
+ if (branchCache && branchCache.cwd === dir && now - branchCache.at < BRANCH_CACHE_TTL_MS) {
200
+ return branchCache.branch;
201
+ }
202
+ let branch = null;
195
203
  try {
196
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
197
- cwd: cwd ?? process.cwd(),
204
+ const out = execSync("git rev-parse --abbrev-ref HEAD", {
205
+ cwd: dir,
198
206
  stdio: ["pipe", "pipe", "pipe"],
199
207
  encoding: "utf8",
200
208
  timeout: 3e3
201
209
  }).trim();
202
- return branch || null;
210
+ branch = out || null;
203
211
  } catch {
204
- return null;
205
212
  }
213
+ branchCache = { cwd: dir, at: now, branch };
214
+ return branch;
206
215
  }
216
+ var configCache = null;
207
217
  function readProjectConfig(projectPath) {
208
218
  const configPath = join2(projectPath ?? process.cwd(), ".q-ring.json");
219
+ let mtimeMs = 0;
209
220
  try {
210
- if (existsSync(configPath)) {
211
- return JSON.parse(readFileSync2(configPath, "utf8"));
212
- }
221
+ mtimeMs = statSync(configPath).mtimeMs;
213
222
  } catch {
214
223
  }
215
- return null;
224
+ if (configCache && configCache.path === configPath && configCache.mtimeMs === mtimeMs) {
225
+ return configCache.config;
226
+ }
227
+ let config = null;
228
+ if (mtimeMs > 0) {
229
+ try {
230
+ config = JSON.parse(readFileSync2(configPath, "utf8"));
231
+ } catch {
232
+ config = null;
233
+ }
234
+ }
235
+ configCache = { path: configPath, mtimeMs, config };
236
+ return config;
216
237
  }
217
238
  function collapseEnvironment(ctx = {}) {
218
239
  if (ctx.explicit) {
@@ -266,38 +287,137 @@ function mapEnvName(raw) {
266
287
 
267
288
  // src/core/observer.ts
268
289
  import {
269
- existsSync as existsSync2,
270
- mkdirSync,
290
+ existsSync,
291
+ mkdirSync as mkdirSync2,
271
292
  appendFileSync,
272
- readFileSync as readFileSync3,
293
+ chmodSync,
294
+ readFileSync as readFileSync4,
273
295
  openSync,
274
296
  fstatSync,
275
297
  readSync,
276
298
  closeSync,
277
- statSync
299
+ statSync as statSync3
300
+ } from "fs";
301
+ import { join as join4 } from "path";
302
+ import { homedir as homedir2 } from "os";
303
+ import { createHash, createHmac, randomBytes } from "crypto";
304
+ import { Entry } from "@napi-rs/keyring";
305
+
306
+ // src/utils/file-lock.ts
307
+ import {
308
+ mkdirSync,
309
+ writeFileSync,
310
+ unlinkSync,
311
+ readFileSync as readFileSync3,
312
+ statSync as statSync2
278
313
  } from "fs";
279
314
  import { join as join3 } from "path";
280
315
  import { homedir } from "os";
281
- import { createHash } from "crypto";
316
+ function sleepSync(ms) {
317
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
318
+ }
319
+ function isProcessAlive(pid) {
320
+ try {
321
+ process.kill(pid, 0);
322
+ return true;
323
+ } catch (err) {
324
+ return err.code === "EPERM";
325
+ }
326
+ }
327
+ function withFileLock(name, fn, opts = {}) {
328
+ const lockDir = join3(homedir(), ".config", "q-ring", opts.dir ?? "locks");
329
+ mkdirSync(lockDir, { recursive: true, mode: 448 });
330
+ const safe = Buffer.from(name, "utf8").toString("base64url");
331
+ const lockPath = join3(lockDir, `${safe}.lock`);
332
+ const deadline = Date.now() + (opts.timeoutMs ?? 8e3);
333
+ const staleMs = opts.staleMs ?? 3e4;
334
+ while (Date.now() < deadline) {
335
+ try {
336
+ writeFileSync(lockPath, `${process.pid}
337
+ `, { flag: "wx", mode: 384 });
338
+ try {
339
+ return fn();
340
+ } finally {
341
+ try {
342
+ unlinkSync(lockPath);
343
+ } catch {
344
+ }
345
+ }
346
+ } catch {
347
+ try {
348
+ const holderPid = parseInt(readFileSync3(lockPath, "utf8").trim(), 10);
349
+ const ageMs = Date.now() - statSync2(lockPath).mtimeMs;
350
+ const stale = Number.isInteger(holderPid) && holderPid > 0 && !isProcessAlive(holderPid) || ageMs > staleMs;
351
+ if (stale) {
352
+ unlinkSync(lockPath);
353
+ continue;
354
+ }
355
+ } catch {
356
+ }
357
+ sleepSync(15);
358
+ }
359
+ }
360
+ throw new Error(`Could not acquire lock "${name}" (timeout)`);
361
+ }
362
+
363
+ // src/core/observer.ts
364
+ var AUDIT_KEYRING_SERVICE = "qring-audit-chain";
365
+ var AUDIT_KEY_ACCOUNT = "hmac-key";
366
+ var AUDIT_ANCHOR_ACCOUNT = "chain-head";
367
+ var warnedNoKeyring = false;
368
+ function getAuditKey() {
369
+ try {
370
+ const entry = new Entry(AUDIT_KEYRING_SERVICE, AUDIT_KEY_ACCOUNT);
371
+ const stored = entry.getPassword();
372
+ if (stored) return Buffer.from(stored, "base64");
373
+ const key = randomBytes(32);
374
+ entry.setPassword(key.toString("base64"));
375
+ return key;
376
+ } catch {
377
+ if (!warnedNoKeyring) {
378
+ console.error(
379
+ "q-ring: WARNING \u2014 OS keyring unavailable; the audit chain has no keyed anchor on this host and is NOT tamper-evident against truncation or full-file rewrite. (In-file SHA-256 chaining still detects edits.)"
380
+ );
381
+ warnedNoKeyring = true;
382
+ }
383
+ return null;
384
+ }
385
+ }
386
+ function headAnchor(line, key) {
387
+ return createHmac("sha256", key).update(line).digest("hex");
388
+ }
389
+ function readStoredAnchor() {
390
+ try {
391
+ return new Entry(AUDIT_KEYRING_SERVICE, AUDIT_ANCHOR_ACCOUNT).getPassword() ?? null;
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+ function writeStoredAnchor(hash) {
397
+ try {
398
+ new Entry(AUDIT_KEYRING_SERVICE, AUDIT_ANCHOR_ACCOUNT).setPassword(hash);
399
+ } catch {
400
+ }
401
+ }
282
402
  function getAuditDir() {
283
403
  if (process.env.QRING_AUDIT_DIR) {
284
- if (!existsSync2(process.env.QRING_AUDIT_DIR)) {
285
- mkdirSync(process.env.QRING_AUDIT_DIR, { recursive: true });
404
+ if (!existsSync(process.env.QRING_AUDIT_DIR)) {
405
+ mkdirSync2(process.env.QRING_AUDIT_DIR, { recursive: true, mode: 448 });
286
406
  }
287
407
  return process.env.QRING_AUDIT_DIR;
288
408
  }
289
- const dir = join3(homedir(), ".config", "q-ring");
290
- if (!existsSync2(dir)) {
291
- mkdirSync(dir, { recursive: true });
409
+ const dir = join4(homedir2(), ".config", "q-ring");
410
+ if (!existsSync(dir)) {
411
+ mkdirSync2(dir, { recursive: true, mode: 448 });
292
412
  }
293
413
  return dir;
294
414
  }
295
415
  function getAuditPath() {
296
- return join3(getAuditDir(), "audit.jsonl");
416
+ return join4(getAuditDir(), "audit.jsonl");
297
417
  }
298
418
  function getLastLineHash() {
299
419
  const path = getAuditPath();
300
- if (!existsSync2(path)) return void 0;
420
+ if (!existsSync(path)) return void 0;
301
421
  try {
302
422
  const fd = openSync(path, "r");
303
423
  const stat = fstatSync(fd);
@@ -319,24 +439,38 @@ function getLastLineHash() {
319
439
  }
320
440
  }
321
441
  function logAudit(event) {
322
- const prevHash = getLastLineHash();
323
- const full = {
324
- ...event,
325
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
326
- pid: process.pid,
327
- prevHash
328
- };
329
442
  try {
330
- appendFileSync(getAuditPath(), JSON.stringify(full) + "\n");
443
+ withFileLock(
444
+ "audit-chain",
445
+ () => {
446
+ const prevHash = getLastLineHash();
447
+ const full = {
448
+ ...event,
449
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
450
+ pid: process.pid,
451
+ prevHash
452
+ };
453
+ const line = JSON.stringify(full);
454
+ const path = getAuditPath();
455
+ appendFileSync(path, line + "\n", { mode: 384 });
456
+ try {
457
+ chmodSync(path, 384);
458
+ } catch {
459
+ }
460
+ const key = getAuditKey();
461
+ if (key) writeStoredAnchor(headAnchor(line, key));
462
+ },
463
+ { timeoutMs: 5e3 }
464
+ );
331
465
  } catch {
332
466
  }
333
467
  }
334
468
  var MAX_AUDIT_BYTES = 12 * 1024 * 1024;
335
469
  function queryAudit(query = {}) {
336
470
  const path = getAuditPath();
337
- if (!existsSync2(path)) return [];
471
+ if (!existsSync(path)) return [];
338
472
  try {
339
- const st = statSync(path);
473
+ const st = statSync3(path);
340
474
  const readStart = st.size > MAX_AUDIT_BYTES ? st.size - MAX_AUDIT_BYTES : 0;
341
475
  const readLen = st.size > MAX_AUDIT_BYTES ? MAX_AUDIT_BYTES : st.size;
342
476
  const buf = Buffer.alloc(readLen);
@@ -375,10 +509,10 @@ function queryAudit(query = {}) {
375
509
  }
376
510
  function verifyAuditChain() {
377
511
  const path = getAuditPath();
378
- if (!existsSync2(path)) {
512
+ if (!existsSync(path)) {
379
513
  return { totalEvents: 0, validEvents: 0, intact: true };
380
514
  }
381
- const lines = readFileSync3(path, "utf8").split("\n").filter((l) => l.trim());
515
+ const lines = readFileSync4(path, "utf8").split("\n").filter((l) => l.trim());
382
516
  if (lines.length === 0) {
383
517
  return { totalEvents: 0, validEvents: 0, intact: true };
384
518
  }
@@ -411,12 +545,26 @@ function verifyAuditChain() {
411
545
  }
412
546
  validEvents++;
413
547
  }
548
+ const key = getAuditKey();
549
+ const anchor = key ? readStoredAnchor() : null;
550
+ if (key && anchor !== null) {
551
+ const head = headAnchor(lines[lines.length - 1], key);
552
+ if (head !== anchor) {
553
+ return {
554
+ totalEvents: lines.length,
555
+ validEvents,
556
+ brokenAt: lines.length - 1,
557
+ intact: false,
558
+ reason: "audit head does not match the keyed anchor in the OS keyring \u2014 the log was truncated or rewritten"
559
+ };
560
+ }
561
+ }
414
562
  return { totalEvents: lines.length, validEvents, intact: true };
415
563
  }
416
564
  function exportAudit(opts = {}) {
417
565
  const path = getAuditPath();
418
- if (!existsSync2(path)) return opts.format === "json" ? "[]" : "";
419
- const lines = readFileSync3(path, "utf8").split("\n").filter((l) => l.trim());
566
+ if (!existsSync(path)) return opts.format === "json" ? "[]" : "";
567
+ const lines = readFileSync4(path, "utf8").split("\n").filter((l) => l.trim());
420
568
  let events = lines.map((l) => {
421
569
  try {
422
570
  return JSON.parse(l);
@@ -481,48 +629,62 @@ function detectAnomalies(key) {
481
629
  }
482
630
 
483
631
  // src/core/entanglement.ts
484
- import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
485
- import { join as join4 } from "path";
486
- import { homedir as homedir2 } from "os";
632
+ import { existsSync as existsSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
633
+ import { join as join5 } from "path";
634
+ import { homedir as homedir3 } from "os";
635
+
636
+ // src/utils/registry.ts
637
+ import { existsSync as existsSync2, readFileSync as readFileSync5, renameSync } from "fs";
638
+ function loadJsonRegistry(path, empty) {
639
+ if (!existsSync2(path)) return empty;
640
+ const raw = readFileSync5(path, "utf8");
641
+ try {
642
+ return JSON.parse(raw);
643
+ } catch (err) {
644
+ const reason = err instanceof Error ? err.message : String(err);
645
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
646
+ const backup = `${path}.corrupt-${stamp}`;
647
+ try {
648
+ renameSync(path, backup);
649
+ } catch {
650
+ throw new Error(
651
+ `q-ring: registry ${path} is corrupt (${reason}) and could not be moved aside \u2014 refusing to continue so a later write cannot overwrite it. Inspect or remove the file manually.`
652
+ );
653
+ }
654
+ console.error(
655
+ `q-ring: WARNING \u2014 registry ${path} was corrupt (${reason}); moved it to ${backup} and reinitialized from empty. Previous entries are preserved in the backup file.`
656
+ );
657
+ return empty;
658
+ }
659
+ }
660
+
661
+ // src/core/entanglement.ts
662
+ var REGISTRY_VERSION = 1;
487
663
  function getRegistryPath() {
488
- const dir = join4(homedir2(), ".config", "q-ring");
664
+ const dir = join5(homedir3(), ".config", "q-ring");
489
665
  if (!existsSync3(dir)) {
490
- mkdirSync2(dir, { recursive: true });
666
+ mkdirSync3(dir, { recursive: true, mode: 448 });
491
667
  }
492
- return join4(dir, "entanglement.json");
668
+ return join5(dir, "entanglement.json");
493
669
  }
494
670
  function loadRegistry() {
495
- const path = getRegistryPath();
496
- if (!existsSync3(path)) {
497
- return { pairs: [] };
498
- }
499
- try {
500
- return JSON.parse(readFileSync4(path, "utf8"));
501
- } catch {
502
- return { pairs: [] };
503
- }
671
+ return loadJsonRegistry(getRegistryPath(), { pairs: [] });
504
672
  }
505
673
  function saveRegistry(registry2) {
506
- writeFileSync(getRegistryPath(), JSON.stringify(registry2, null, 2), {
674
+ registry2.version = REGISTRY_VERSION;
675
+ writeFileSync2(getRegistryPath(), JSON.stringify(registry2, null, 2), {
507
676
  mode: 384
508
677
  });
509
678
  }
510
- function entangle(source, target) {
679
+ function entangle(source, target, createdBy) {
511
680
  const registry2 = loadRegistry();
512
681
  const exists = registry2.pairs.some(
513
682
  (p) => p.source.service === source.service && p.source.key === source.key && p.target.service === target.service && p.target.key === target.key
514
683
  );
515
684
  if (!exists) {
516
- registry2.pairs.push({
517
- source,
518
- target,
519
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
520
- });
521
- registry2.pairs.push({
522
- source: target,
523
- target: source,
524
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
525
- });
685
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
686
+ registry2.pairs.push({ source, target, createdAt, createdBy });
687
+ registry2.pairs.push({ source: target, target: source, createdAt, createdBy });
526
688
  saveRegistry(registry2);
527
689
  }
528
690
  }
@@ -544,9 +706,9 @@ function listEntanglements() {
544
706
  }
545
707
 
546
708
  // src/core/hooks.ts
547
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
548
- import { join as join5 } from "path";
549
- import { homedir as homedir3 } from "os";
709
+ import { existsSync as existsSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
710
+ import { join as join6 } from "path";
711
+ import { homedir as homedir4 } from "os";
550
712
  import { execFile, spawn } from "child_process";
551
713
  import { randomUUID } from "crypto";
552
714
 
@@ -770,25 +932,17 @@ function httpRequest(opts) {
770
932
 
771
933
  // src/core/hooks.ts
772
934
  function getRegistryPath2() {
773
- const dir = join5(homedir3(), ".config", "q-ring");
935
+ const dir = join6(homedir4(), ".config", "q-ring");
774
936
  if (!existsSync4(dir)) {
775
- mkdirSync3(dir, { recursive: true });
937
+ mkdirSync4(dir, { recursive: true, mode: 448 });
776
938
  }
777
- return join5(dir, "hooks.json");
939
+ return join6(dir, "hooks.json");
778
940
  }
779
941
  function loadRegistry2() {
780
- const path = getRegistryPath2();
781
- if (!existsSync4(path)) {
782
- return { hooks: [] };
783
- }
784
- try {
785
- return JSON.parse(readFileSync5(path, "utf8"));
786
- } catch {
787
- return { hooks: [] };
788
- }
942
+ return loadJsonRegistry(getRegistryPath2(), { hooks: [] });
789
943
  }
790
944
  function saveRegistry2(registry2) {
791
- writeFileSync2(getRegistryPath2(), JSON.stringify(registry2, null, 2), {
945
+ writeFileSync3(getRegistryPath2(), JSON.stringify(registry2, null, 2), {
792
946
  mode: 384
793
947
  });
794
948
  }
@@ -1051,19 +1205,19 @@ async function fireHooks(payload, tags) {
1051
1205
  }
1052
1206
 
1053
1207
  // src/core/approval.ts
1054
- import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
1055
- import { join as join6 } from "path";
1056
- import { homedir as homedir4 } from "os";
1057
- import { createHmac, randomBytes, timingSafeEqual } from "crypto";
1208
+ import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5 } from "fs";
1209
+ import { join as join7 } from "path";
1210
+ import { homedir as homedir5 } from "os";
1211
+ import { createHmac as createHmac2, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
1058
1212
  function getHmacSecret() {
1059
- const dir = join6(homedir4(), ".config", "q-ring");
1060
- const secretPath = join6(dir, ".approval-key");
1061
- if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
1213
+ const dir = join7(homedir5(), ".config", "q-ring");
1214
+ const secretPath = join7(dir, ".approval-key");
1215
+ if (!existsSync5(dir)) mkdirSync5(dir, { recursive: true, mode: 448 });
1062
1216
  if (existsSync5(secretPath)) {
1063
1217
  return readFileSync6(secretPath, "utf8").trim();
1064
1218
  }
1065
- const secret = randomBytes(32).toString("hex");
1066
- writeFileSync3(secretPath, secret, { mode: 384 });
1219
+ const secret = randomBytes2(32).toString("hex");
1220
+ writeFileSync4(secretPath, secret, { mode: 384 });
1067
1221
  return secret;
1068
1222
  }
1069
1223
  function computeHmac(entry) {
@@ -1071,6 +1225,7 @@ function computeHmac(entry) {
1071
1225
  entry.id,
1072
1226
  entry.key,
1073
1227
  entry.scope,
1228
+ entry.service ?? "",
1074
1229
  entry.reason,
1075
1230
  entry.grantedBy,
1076
1231
  entry.grantedAt,
@@ -1078,7 +1233,7 @@ function computeHmac(entry) {
1078
1233
  entry.workspace ?? "",
1079
1234
  entry.sessionId ?? ""
1080
1235
  ].join("|");
1081
- return createHmac("sha256", getHmacSecret()).update(payload).digest("hex");
1236
+ return createHmac2("sha256", getHmacSecret()).update(payload).digest("hex");
1082
1237
  }
1083
1238
  function verifyHmac(entry) {
1084
1239
  const expected = computeHmac(entry);
@@ -1092,25 +1247,17 @@ function verifyHmac(entry) {
1092
1247
  }
1093
1248
  }
1094
1249
  function getRegistryPath3() {
1095
- const dir = join6(homedir4(), ".config", "q-ring");
1250
+ const dir = join7(homedir5(), ".config", "q-ring");
1096
1251
  if (!existsSync5(dir)) {
1097
- mkdirSync4(dir, { recursive: true, mode: 448 });
1252
+ mkdirSync5(dir, { recursive: true, mode: 448 });
1098
1253
  }
1099
- return join6(dir, "approvals.json");
1254
+ return join7(dir, "approvals.json");
1100
1255
  }
1101
1256
  function loadRegistry3() {
1102
- const path = getRegistryPath3();
1103
- if (!existsSync5(path)) {
1104
- return { approvals: [] };
1105
- }
1106
- try {
1107
- return JSON.parse(readFileSync6(path, "utf8"));
1108
- } catch {
1109
- return { approvals: [] };
1110
- }
1257
+ return loadJsonRegistry(getRegistryPath3(), { approvals: [] });
1111
1258
  }
1112
1259
  function saveRegistry3(registry2) {
1113
- writeFileSync3(getRegistryPath3(), JSON.stringify(registry2, null, 2), { mode: 384 });
1260
+ writeFileSync4(getRegistryPath3(), JSON.stringify(registry2, null, 2), { mode: 384 });
1114
1261
  }
1115
1262
  function cleanup(registry2) {
1116
1263
  const now = Date.now();
@@ -1118,16 +1265,17 @@ function cleanup(registry2) {
1118
1265
  (a) => new Date(a.expiresAt).getTime() > now
1119
1266
  );
1120
1267
  }
1121
- function grantApproval(key, scope, ttlSeconds = 3600, grantOpts = {}) {
1268
+ function grantApproval(key, scope, service, ttlSeconds = 3600, grantOpts = {}) {
1122
1269
  const registry2 = loadRegistry3();
1123
1270
  cleanup(registry2);
1124
- const id = randomBytes(8).toString("hex");
1271
+ const id = randomBytes2(8).toString("hex");
1125
1272
  const grantedAt = (/* @__PURE__ */ new Date()).toISOString();
1126
1273
  const expiresAt = new Date(Date.now() + ttlSeconds * 1e3).toISOString();
1127
1274
  const partial = {
1128
1275
  id,
1129
1276
  key,
1130
1277
  scope,
1278
+ service,
1131
1279
  reason: grantOpts.reason ?? "no reason provided",
1132
1280
  grantedBy: grantOpts.grantedBy ?? "cli-user",
1133
1281
  grantedAt,
@@ -1137,7 +1285,7 @@ function grantApproval(key, scope, ttlSeconds = 3600, grantOpts = {}) {
1137
1285
  };
1138
1286
  const entry = { ...partial, hmac: computeHmac(partial) };
1139
1287
  const existingIdx = registry2.approvals.findIndex(
1140
- (a) => a.key === key && a.scope === scope
1288
+ (a) => a.key === key && a.scope === scope && a.service === service
1141
1289
  );
1142
1290
  if (existingIdx >= 0) {
1143
1291
  registry2.approvals[existingIdx] = entry;
@@ -1147,25 +1295,28 @@ function grantApproval(key, scope, ttlSeconds = 3600, grantOpts = {}) {
1147
1295
  saveRegistry3(registry2);
1148
1296
  return entry;
1149
1297
  }
1150
- function revokeApproval(key, scope) {
1298
+ function revokeApproval(key, scope, service) {
1151
1299
  const registry2 = loadRegistry3();
1152
1300
  const before = registry2.approvals.length;
1153
1301
  registry2.approvals = registry2.approvals.filter(
1154
- (a) => !(a.key === key && a.scope === scope)
1302
+ (a) => !(a.key === key && a.scope === scope && a.service === service)
1155
1303
  );
1156
1304
  saveRegistry3(registry2);
1157
1305
  return registry2.approvals.length < before;
1158
1306
  }
1159
- function hasApproval(key, scope) {
1307
+ function hasApproval(key, scope, service) {
1160
1308
  const registry2 = loadRegistry3();
1161
1309
  const entry = registry2.approvals.find(
1162
- (a) => a.key === key && a.scope === scope
1310
+ (a) => a.key === key && a.scope === scope && a.service === service
1163
1311
  );
1164
1312
  if (!entry) return false;
1165
1313
  if (new Date(entry.expiresAt).getTime() < Date.now()) return false;
1166
1314
  if (!verifyHmac(entry)) return false;
1167
1315
  return true;
1168
1316
  }
1317
+ function countLegacyApprovals() {
1318
+ return loadRegistry3().approvals.filter((a) => a.service === void 0).length;
1319
+ }
1169
1320
  function listApprovals() {
1170
1321
  const registry2 = loadRegistry3();
1171
1322
  const now = Date.now();
@@ -1177,16 +1328,50 @@ function listApprovals() {
1177
1328
  }
1178
1329
 
1179
1330
  // src/core/policy.ts
1180
- import { statSync as statSync2 } from "fs";
1181
- import { join as join7 } from "path";
1331
+ import { statSync as statSync4 } from "fs";
1332
+ import { join as join8 } from "path";
1333
+ import { z as z2 } from "zod";
1334
+ var stringArray = z2.array(z2.string());
1335
+ var mcpPolicySchema = z2.object({
1336
+ allowTools: stringArray.optional(),
1337
+ denyTools: stringArray.optional(),
1338
+ readableKeys: stringArray.optional(),
1339
+ deniedKeys: stringArray.optional(),
1340
+ deniedTags: stringArray.optional()
1341
+ }).strict();
1342
+ var execPolicySchema = z2.object({
1343
+ allowCommands: stringArray.optional(),
1344
+ denyCommands: stringArray.optional(),
1345
+ maxRuntimeSeconds: z2.number().optional(),
1346
+ allowNetwork: z2.boolean().optional()
1347
+ }).strict();
1348
+ var secretsPolicySchema = z2.object({
1349
+ requireApprovalForTags: stringArray.optional(),
1350
+ requireRotationFormatForTags: stringArray.optional(),
1351
+ maxTtlSeconds: z2.number().optional()
1352
+ }).strict();
1353
+ var policySchema = z2.object({
1354
+ mcp: mcpPolicySchema.optional(),
1355
+ exec: execPolicySchema.optional(),
1356
+ secrets: secretsPolicySchema.optional()
1357
+ }).strict();
1358
+ var PolicyConfigError = class extends Error {
1359
+ constructor(message) {
1360
+ super(message);
1361
+ this.name = "PolicyConfigError";
1362
+ }
1363
+ };
1182
1364
  var cachedPolicy = null;
1183
1365
  var policyRoot = null;
1366
+ function getPolicyRoot() {
1367
+ return policyRoot;
1368
+ }
1184
1369
  function resolvePolicyPath(projectPath) {
1185
1370
  return policyRoot ?? projectPath ?? process.cwd();
1186
1371
  }
1187
1372
  function configMtime(pp) {
1188
1373
  try {
1189
- return statSync2(join7(pp, ".q-ring.json")).mtimeMs;
1374
+ return statSync4(join8(pp, ".q-ring.json")).mtimeMs;
1190
1375
  } catch {
1191
1376
  return 0;
1192
1377
  }
@@ -1195,12 +1380,28 @@ function loadPolicy(projectPath) {
1195
1380
  const pp = resolvePolicyPath(projectPath);
1196
1381
  const mtimeMs = configMtime(pp);
1197
1382
  if (cachedPolicy && cachedPolicy.path === pp && cachedPolicy.mtimeMs === mtimeMs) {
1383
+ if (cachedPolicy.error) throw cachedPolicy.error;
1198
1384
  return cachedPolicy.policy;
1199
1385
  }
1200
1386
  const config = readProjectConfig(pp);
1201
- const policy = config?.policy ?? {};
1202
- cachedPolicy = { path: pp, mtimeMs, policy };
1203
- return policy;
1387
+ const rawPolicy = config?.policy;
1388
+ if (rawPolicy === void 0 || rawPolicy === null) {
1389
+ const policy = {};
1390
+ cachedPolicy = { path: pp, mtimeMs, policy };
1391
+ return policy;
1392
+ }
1393
+ const parsed = policySchema.safeParse(rawPolicy);
1394
+ if (!parsed.success) {
1395
+ const issues = parsed.error.issues.map((i) => `policy${i.path.length ? "." + i.path.join(".") : ""}: ${i.message}`).join("; ");
1396
+ const error = new PolicyConfigError(
1397
+ `Invalid policy in ${join8(pp, ".q-ring.json")} \u2014 refusing to run under an unparseable security policy (fail closed). Fix these and retry: ${issues}`
1398
+ );
1399
+ console.error(`q-ring: ${error.message}`);
1400
+ cachedPolicy = { path: pp, mtimeMs, error };
1401
+ throw error;
1402
+ }
1403
+ cachedPolicy = { path: pp, mtimeMs, policy: parsed.data };
1404
+ return parsed.data;
1204
1405
  }
1205
1406
  function checkSecretLifecyclePolicy(input, projectPath) {
1206
1407
  const policy = loadPolicy(projectPath);
@@ -1310,10 +1511,7 @@ function getPolicySummary(projectPath) {
1310
1511
  }
1311
1512
 
1312
1513
  // src/core/keyring.ts
1313
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, unlinkSync } from "fs";
1314
- import { homedir as homedir5 } from "os";
1315
- import { join as join8 } from "path";
1316
- import { Entry, findCredentials } from "@napi-rs/keyring";
1514
+ import { Entry as Entry2, findCredentials } from "@napi-rs/keyring";
1317
1515
 
1318
1516
  // src/utils/hash.ts
1319
1517
  import { createHash as createHash2 } from "crypto";
@@ -1366,22 +1564,25 @@ function resolveScope(opts) {
1366
1564
  chain.push({ scope: "global", service: globalService() });
1367
1565
  return chain;
1368
1566
  }
1567
+ function serviceForScope(scope, opts = {}) {
1568
+ return resolveScope({ ...opts, scope })[0].service;
1569
+ }
1369
1570
 
1370
1571
  // src/core/provision.ts
1371
1572
  import { execFileSync, spawnSync } from "child_process";
1372
- import { z as z2 } from "zod";
1373
- var AwsStsConfigSchema = z2.object({
1374
- roleArn: z2.string(),
1375
- sessionName: z2.string().optional(),
1376
- durationSeconds: z2.number().optional()
1573
+ import { z as z3 } from "zod";
1574
+ var AwsStsConfigSchema = z3.object({
1575
+ roleArn: z3.string(),
1576
+ sessionName: z3.string().optional(),
1577
+ durationSeconds: z3.number().optional()
1377
1578
  });
1378
- var HttpJitConfigSchema = z2.object({
1379
- url: z2.string(),
1380
- method: z2.string().optional(),
1381
- valuePath: z2.string().optional(),
1382
- expiresInSeconds: z2.number().optional(),
1383
- headers: z2.record(z2.string(), z2.string()).optional(),
1384
- body: z2.unknown().optional()
1579
+ var HttpJitConfigSchema = z3.object({
1580
+ url: z3.string(),
1581
+ method: z3.string().optional(),
1582
+ valuePath: z3.string().optional(),
1583
+ expiresInSeconds: z3.number().optional(),
1584
+ headers: z3.record(z3.string(), z3.string()).optional(),
1585
+ body: z3.unknown().optional()
1385
1586
  });
1386
1587
  var ProvisionRegistry = class {
1387
1588
  providers = /* @__PURE__ */ new Map();
@@ -1516,40 +1717,17 @@ registry.register(httpProvider);
1516
1717
 
1517
1718
  // src/core/keyring.ts
1518
1719
  function withJitEnvelopeLock(service, key, fn) {
1519
- const dir = join8(homedir5(), ".config", "q-ring", "jit-locks");
1520
- mkdirSync5(dir, { recursive: true });
1521
- const safe = Buffer.from(`${service}\0${key}`, "utf8").toString("base64url");
1522
- const lockPath = join8(dir, `${safe}.lock`);
1523
- const deadline = Date.now() + 8e3;
1524
- while (Date.now() < deadline) {
1525
- try {
1526
- writeFileSync4(lockPath, `${process.pid}
1527
- `, { flag: "wx", mode: 384 });
1528
- try {
1529
- return fn();
1530
- } finally {
1531
- try {
1532
- unlinkSync(lockPath);
1533
- } catch {
1534
- }
1535
- }
1536
- } catch {
1537
- const start = Date.now();
1538
- while (Date.now() - start < 15) {
1539
- }
1540
- }
1541
- }
1542
- throw new Error("Could not acquire JIT envelope lock (timeout)");
1720
+ return withFileLock(`${service}\0${key}`, fn, { dir: "jit-locks" });
1543
1721
  }
1544
1722
  function readEnvelope(service, key) {
1545
- const entry = new Entry(service, key);
1723
+ const entry = new Entry2(service, key);
1546
1724
  const raw = entry.getPassword();
1547
1725
  if (raw === null) return null;
1548
1726
  const envelope = parseEnvelope(raw);
1549
1727
  return envelope ?? wrapLegacy(raw);
1550
1728
  }
1551
1729
  function writeEnvelope(service, key, envelope) {
1552
- const entry = new Entry(service, key);
1730
+ const entry = new Entry2(service, key);
1553
1731
  entry.setPassword(serializeEnvelope(envelope));
1554
1732
  }
1555
1733
  function resolveEnv(opts) {
@@ -1617,7 +1795,7 @@ function getSecret(key, opts = {}) {
1617
1795
  continue;
1618
1796
  }
1619
1797
  if (envelope.meta.requiresApproval && source === "mcp") {
1620
- if (!hasApproval(key, scope)) {
1798
+ if (!hasApproval(key, scope, service)) {
1621
1799
  if (!opts.silent) {
1622
1800
  logAudit({
1623
1801
  action: "read",
@@ -1667,8 +1845,8 @@ function getSecret(key, opts = {}) {
1667
1845
  }
1668
1846
  value = resolveTemplates(value, { ...opts, _seen: nextSeen }, nextSeen);
1669
1847
  if (!opts.silent) {
1670
- const updated = recordAccess(envelope);
1671
- writeEnvelope(service, key, updated);
1848
+ const latest = readEnvelope(service, key) ?? envelope;
1849
+ writeEnvelope(service, key, recordAccess(latest));
1672
1850
  logAudit({ action: "read", key, scope, env, source });
1673
1851
  }
1674
1852
  return value;
@@ -1754,6 +1932,19 @@ function setSecret(key, value, opts = {}) {
1754
1932
  const entangled = findEntangled({ service, key });
1755
1933
  for (const target of entangled) {
1756
1934
  try {
1935
+ if (source === "mcp") {
1936
+ const decision = checkKeyReadPolicy(target.key, void 0, opts.projectPath);
1937
+ if (!decision.allowed) {
1938
+ logAudit({
1939
+ action: "entangle",
1940
+ key: target.key,
1941
+ scope: "global",
1942
+ source,
1943
+ detail: `blocked propagation from ${key}: ${decision.reason}`
1944
+ });
1945
+ continue;
1946
+ }
1947
+ }
1757
1948
  const targetEnvelope = readEnvelope(target.service, target.key);
1758
1949
  if (targetEnvelope) {
1759
1950
  if (opts.states) {
@@ -1794,7 +1985,7 @@ function deleteSecret(key, opts = {}) {
1794
1985
  }
1795
1986
  let deleted = false;
1796
1987
  for (const { service, scope } of scopes) {
1797
- const entry = new Entry(service, key);
1988
+ const entry = new Entry2(service, key);
1798
1989
  try {
1799
1990
  if (entry.deleteCredential()) {
1800
1991
  deleted = true;
@@ -1903,7 +2094,7 @@ function exportSecrets(opts = {}) {
1903
2094
  if (entry.envelope) {
1904
2095
  const decay = checkDecay(entry.envelope);
1905
2096
  if (decay.isExpired) continue;
1906
- if (source === "mcp" && entry.envelope.meta.requiresApproval && !hasApproval(entry.key, entry.scope)) {
2097
+ if (source === "mcp" && entry.envelope.meta.requiresApproval && !hasApproval(entry.key, entry.scope, serviceForScope(entry.scope, opts))) {
1907
2098
  logAudit({
1908
2099
  action: "read",
1909
2100
  key: entry.key,
@@ -1948,7 +2139,10 @@ function entangleSecrets(sourceKey, sourceOpts, targetKey, targetOpts) {
1948
2139
  const targetScopes = resolveScope({ ...targetOpts, scope: targetOpts.scope ?? "global" });
1949
2140
  const source = { service: sourceScopes[0].service, key: sourceKey };
1950
2141
  const target = { service: targetScopes[0].service, key: targetKey };
1951
- entangle(source, target);
2142
+ entangle(source, target, {
2143
+ source: sourceOpts.source ?? "cli",
2144
+ policyRoot: getPolicyRoot()
2145
+ });
1952
2146
  logAudit({
1953
2147
  action: "entangle",
1954
2148
  key: sourceKey,
@@ -1971,7 +2165,7 @@ function disentangleSecrets(sourceKey, sourceOpts, targetKey, targetOpts) {
1971
2165
  }
1972
2166
 
1973
2167
  // src/core/tunnel.ts
1974
- import { randomBytes as randomBytes2 } from "crypto";
2168
+ import { randomBytes as randomBytes3 } from "crypto";
1975
2169
  var tunnelStore = /* @__PURE__ */ new Map();
1976
2170
  var cleanupInterval = null;
1977
2171
  function ensureCleanup() {
@@ -1993,7 +2187,7 @@ function ensureCleanup() {
1993
2187
  }
1994
2188
  }
1995
2189
  function tunnelCreate(value, opts = {}) {
1996
- const id = `tun_${Date.now().toString(36)}_${randomBytes2(6).toString("base64url")}`;
2190
+ const id = `tun_${Date.now().toString(36)}_${randomBytes3(6).toString("base64url")}`;
1997
2191
  const now = Date.now();
1998
2192
  tunnelStore.set(id, {
1999
2193
  value,
@@ -2043,43 +2237,72 @@ function tunnelList() {
2043
2237
  }
2044
2238
 
2045
2239
  // src/core/memory.ts
2046
- import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5, mkdirSync as mkdirSync6 } from "fs";
2240
+ import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5, mkdirSync as mkdirSync6, chmodSync as chmodSync2 } from "fs";
2047
2241
  import { join as join9 } from "path";
2048
2242
  import { homedir as homedir6, hostname, userInfo } from "os";
2049
- import { createCipheriv, createDecipheriv, createHash as createHash3, randomBytes as randomBytes3 } from "crypto";
2050
- import { Entry as Entry2 } from "@napi-rs/keyring";
2243
+ import {
2244
+ createCipheriv,
2245
+ createDecipheriv,
2246
+ createHash as createHash3,
2247
+ randomBytes as randomBytes4,
2248
+ pbkdf2Sync
2249
+ } from "crypto";
2250
+ import { Entry as Entry3 } from "@napi-rs/keyring";
2051
2251
  var MEMORY_FILE = "agent-memory.enc";
2052
2252
  var KEYRING_SERVICE = "qring-memory-key";
2053
2253
  var KEYRING_ACCOUNT = "encryption-key";
2054
2254
  function getMemoryDir() {
2055
2255
  const dir = join9(homedir6(), ".config", "q-ring");
2056
2256
  if (!existsSync6(dir)) {
2057
- mkdirSync6(dir, { recursive: true });
2257
+ mkdirSync6(dir, { recursive: true, mode: 448 });
2058
2258
  }
2059
2259
  return dir;
2060
2260
  }
2261
+ function writeMemoryFile(path, data) {
2262
+ writeFileSync5(path, data, { mode: 384 });
2263
+ try {
2264
+ chmodSync2(path, 384);
2265
+ } catch {
2266
+ }
2267
+ }
2061
2268
  function getMemoryPath() {
2062
2269
  return join9(getMemoryDir(), MEMORY_FILE);
2063
2270
  }
2271
+ var PBKDF2_ITERATIONS = 21e4;
2272
+ var KEY_LENGTH = 32;
2273
+ var PASSPHRASE_ENV = "QRING_MEMORY_PASSPHRASE";
2274
+ var V2_PREFIX = "qmem2";
2275
+ var MemoryKeyUnavailableError = class extends Error {
2276
+ constructor(message) {
2277
+ super(message);
2278
+ this.name = "MemoryKeyUnavailableError";
2279
+ }
2280
+ };
2064
2281
  function deriveLegacyKey() {
2065
2282
  const fingerprint = `qring-memory:${hostname()}:${userInfo().username}`;
2066
2283
  return createHash3("sha256").update(fingerprint).digest();
2067
2284
  }
2068
- function getOrCreateKey() {
2285
+ function passphrase() {
2286
+ const p = process.env[PASSPHRASE_ENV];
2287
+ return p && p.length > 0 ? p : void 0;
2288
+ }
2289
+ function derivePassphraseKey(salt) {
2290
+ return pbkdf2Sync(passphrase(), salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha512");
2291
+ }
2292
+ function keyringKey() {
2069
2293
  try {
2070
- const entry = new Entry2(KEYRING_SERVICE, KEYRING_ACCOUNT);
2294
+ const entry = new Entry3(KEYRING_SERVICE, KEYRING_ACCOUNT);
2071
2295
  const stored = entry.getPassword();
2072
2296
  if (stored) return Buffer.from(stored, "base64");
2073
- const key = randomBytes3(32);
2297
+ const key = randomBytes4(KEY_LENGTH);
2074
2298
  entry.setPassword(key.toString("base64"));
2075
2299
  return key;
2076
2300
  } catch {
2077
- console.warn("[q-ring] OS keyring unavailable for memory key \u2014 falling back to machine-derived key");
2078
- return deriveLegacyKey();
2301
+ return null;
2079
2302
  }
2080
2303
  }
2081
2304
  function encryptWith(data, key) {
2082
- const iv = randomBytes3(12);
2305
+ const iv = randomBytes4(12);
2083
2306
  const cipher = createCipheriv("aes-256-gcm", key, iv);
2084
2307
  const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]);
2085
2308
  const tag = cipher.getAuthTag();
@@ -2096,18 +2319,44 @@ function decryptWith(blob, key) {
2096
2319
  return decipher.update(encrypted) + decipher.final("utf8");
2097
2320
  }
2098
2321
  function encrypt(data) {
2099
- return encryptWith(data, getOrCreateKey());
2322
+ const kk = keyringKey();
2323
+ if (kk) return encryptWith(data, kk);
2324
+ if (passphrase()) {
2325
+ const salt = randomBytes4(16);
2326
+ const key = derivePassphraseKey(salt);
2327
+ return `${V2_PREFIX}:${salt.toString("base64")}:${encryptWith(data, key)}`;
2328
+ }
2329
+ throw new MemoryKeyUnavailableError(
2330
+ `Cannot persist agent memory: the OS keyring is unavailable and ${PASSPHRASE_ENV} is not set. Refusing to encrypt with a machine-derivable key (any local process could recompute it and read your memory). Set ${PASSPHRASE_ENV} to a strong passphrase, or run on a host with an OS keyring.`
2331
+ );
2100
2332
  }
2101
2333
  function decrypt(blob) {
2102
- const key = getOrCreateKey();
2103
- try {
2104
- return decryptWith(blob, key);
2105
- } catch {
2106
- const legacy = deriveLegacyKey();
2107
- const plain = decryptWith(blob, legacy);
2108
- writeFileSync5(getMemoryPath(), encryptWith(plain, key), "utf8");
2109
- return plain;
2334
+ if (blob.startsWith(`${V2_PREFIX}:`)) {
2335
+ if (!passphrase()) {
2336
+ throw new MemoryKeyUnavailableError(
2337
+ `Agent memory was encrypted with ${PASSPHRASE_ENV} but it is not set \u2014 cannot decrypt.`
2338
+ );
2339
+ }
2340
+ const rest = blob.slice(V2_PREFIX.length + 1);
2341
+ const sep = rest.indexOf(":");
2342
+ const salt = Buffer.from(rest.slice(0, sep), "base64");
2343
+ return decryptWith(rest.slice(sep + 1), derivePassphraseKey(salt));
2110
2344
  }
2345
+ const kk = keyringKey();
2346
+ if (kk) {
2347
+ try {
2348
+ return decryptWith(blob, kk);
2349
+ } catch {
2350
+ }
2351
+ }
2352
+ const plain = decryptWith(blob, deriveLegacyKey());
2353
+ if (kk) {
2354
+ try {
2355
+ writeMemoryFile(getMemoryPath(), encryptWith(plain, kk));
2356
+ } catch {
2357
+ }
2358
+ }
2359
+ return plain;
2111
2360
  }
2112
2361
  function loadStore() {
2113
2362
  const path = getMemoryPath();
@@ -2118,14 +2367,17 @@ function loadStore() {
2118
2367
  const raw = readFileSync7(path, "utf8");
2119
2368
  const decrypted = decrypt(raw);
2120
2369
  return JSON.parse(decrypted);
2121
- } catch {
2370
+ } catch (err) {
2371
+ if (err instanceof MemoryKeyUnavailableError) {
2372
+ console.error(`q-ring: ${err.message}`);
2373
+ }
2122
2374
  return { entries: {} };
2123
2375
  }
2124
2376
  }
2125
2377
  function saveStore(store) {
2126
2378
  const json = JSON.stringify(store);
2127
2379
  const encrypted = encrypt(json);
2128
- writeFileSync5(getMemoryPath(), encrypted, "utf8");
2380
+ writeMemoryFile(getMemoryPath(), encrypted);
2129
2381
  }
2130
2382
  function remember(key, value) {
2131
2383
  const store = loadStore();
@@ -2161,6 +2413,7 @@ function clearMemory() {
2161
2413
 
2162
2414
  export {
2163
2415
  PACKAGE_VERSION,
2416
+ serviceForScope,
2164
2417
  checkDecay,
2165
2418
  readProjectConfig,
2166
2419
  collapseEnvironment,
@@ -2180,6 +2433,7 @@ export {
2180
2433
  fireHooks,
2181
2434
  grantApproval,
2182
2435
  revokeApproval,
2436
+ countLegacyApprovals,
2183
2437
  listApprovals,
2184
2438
  registry,
2185
2439
  checkExecPolicy,
@@ -2204,4 +2458,4 @@ export {
2204
2458
  forget,
2205
2459
  clearMemory
2206
2460
  };
2207
- //# sourceMappingURL=chunk-Z7UJFHVE.js.map
2461
+ //# sourceMappingURL=chunk-C2TFJ2EH.js.map