@i4ctime/q-ring 0.11.7 → 0.12.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.
package/README.md CHANGED
@@ -182,7 +182,7 @@ qring tunnel list
182
182
 
183
183
  ### Teleportation — Encrypted Sharing
184
184
 
185
- Pack secrets into AES-256-GCM encrypted bundles for secure transfer between machines.
185
+ Pack secrets into AES-256-GCM encrypted bundles for secure transfer between machines. Keys are derived with PBKDF2-HMAC-SHA512 (210 000 iterations) from your passphrase; each bundle records its iteration count, so bundles produced by older versions still unpack.
186
186
 
187
187
  ```bash
188
188
  # Pack secrets (prompts for passphrase)
@@ -197,7 +197,7 @@ qring teleport unpack <bundle> --dry-run
197
197
 
198
198
  ### Import — Bulk Secret Ingestion
199
199
 
200
- Import secrets from `.env` files directly into q-ring. Supports standard dotenv syntax including comments, quoted values, and escape sequences.
200
+ Import secrets from `.env` files directly into q-ring. Supports standard dotenv syntax including comments, quoted values, and escape sequences. The CLI accepts either a file path or raw content; the `import_dotenv` MCP tool only accepts raw content (it never reads files from disk) so an agent can't coerce it into reading arbitrary local files.
201
201
 
202
202
  ```bash
203
203
  # Import all secrets from a .env file
@@ -335,7 +335,7 @@ qring hook test <id>
335
335
 
336
336
  Hooks are fire-and-forget: a failing hook never blocks secret operations. The hook registry is stored at `~/.config/q-ring/hooks.json`.
337
337
 
338
- **SSRF protection:** HTTP hook URLs targeting private/loopback IP ranges (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, `fc00::/7`) are blocked by default. DNS resolution is checked before the request is sent. To allow hooks targeting local services (e.g. during development), set the environment variable `Q_RING_ALLOW_PRIVATE_HOOKS=1`.
338
+ **SSRF protection:** HTTP hook URLs targeting private/loopback IP ranges (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, `fc00::/7`) are blocked by default. DNS is checked up front *and* re-validated at connect time, so a hostname can't pass the check then rebind to a private address before the socket opens. To allow hooks targeting local services (e.g. during development), set the environment variable `Q_RING_ALLOW_PRIVATE_HOOKS=1`.
339
339
 
340
340
  ### Configurable Rotation
341
341
 
@@ -397,7 +397,7 @@ qring get DB_URL
397
397
 
398
398
  ### User Approvals (Zero-Trust Agent)
399
399
 
400
- Protect sensitive production secrets from being read autonomously by the MCP server without explicit user approval. Each approval token is HMAC-verified, scoped, reasoned, and time-limited.
400
+ Protect sensitive production secrets from being read autonomously by the MCP server without explicit user approval. Each approval token is HMAC-verified, scoped, reasoned, and time-limited. The gate applies to bulk reads too — `export_secrets` and `teleport_pack` over MCP skip approval-protected keys that lack a valid grant.
401
401
 
402
402
  ```bash
403
403
  # Mark a secret as requiring approval
@@ -508,6 +508,8 @@ qring wizard myservice --hook-exec "pm2 restart app"
508
508
 
509
509
  Define project-level governance rules in `.q-ring.json` to control which MCP tools can be used, which keys are accessible, and which commands can be executed. Policy is enforced at both the MCP server and keyring level.
510
510
 
511
+ Over MCP, policy is resolved from the directory the server was **launched** in — not from the `projectPath` a caller passes — so an agent can't sidestep restrictions by pointing at a directory with no policy. Launch the MCP server from your project root (where `.q-ring.json` lives). Edits to `.q-ring.json` are picked up automatically (the policy cache invalidates on file change), so you don't need to restart the server.
512
+
511
513
  ```bash
512
514
  # View the active policy
513
515
  qring policy
@@ -626,7 +628,7 @@ qring agent --once
626
628
 
627
629
  ### Quantum Status Dashboard — Live Monitoring
628
630
 
629
- Launch a real-time dashboard in your browser that turns the entire quantum subsystem into one glanceable page. The dashboard is a single self-contained HTML page served locally — no dependencies, no cloud, no config — and streams updates every 5 seconds via Server-Sent Events while preserving search input and scroll position across ticks.
631
+ Launch a real-time dashboard in your browser that turns the entire quantum subsystem into one glanceable page. It's a single self-contained HTML page served locally — no cloud, no config, fully offline — built as a Preact + htm app (runtime bundled and inlined). It streams updates every 5 seconds via Server-Sent Events and diffs the DOM in place, so data refreshes without re-running entrance animations and your search input, caret, and scroll position are preserved across ticks.
630
632
 
631
633
  What you get:
632
634
 
@@ -644,14 +646,16 @@ What you get:
644
646
 
645
647
  Top-bar controls let you **pause** SSE updates (handy while reading the audit feed), **refresh** on demand, or jump to the raw JSON snapshot at `/api/status`. Keyboard shortcuts: `/` focus secrets search · `P` pause · `R` refresh.
646
648
 
649
+ The dashboard binds to `127.0.0.1` only and **never** exposes secret values, but it does surface key names, the audit log, and approval grants — so every route is gated by a random, per-launch token. `qring status` prints (and opens) the full URL including `?token=…`; requests without the token get a `403`. Stop the server to invalidate the token.
650
+
647
651
  ```bash
648
- # Open the dashboard (auto-launches your browser)
652
+ # Open the dashboard (auto-launches your browser at http://127.0.0.1:9876/?token=…)
649
653
  qring status
650
654
 
651
655
  # Specify a custom port
652
656
  qring status --port 4200
653
657
 
654
- # Don't auto-open the browser
658
+ # Don't auto-open the browser (copy the printed tokenized URL yourself)
655
659
  qring status --no-open
656
660
  ```
657
661
 
@@ -668,8 +672,8 @@ q-ring includes a full MCP server with 44 tools for AI agent integration.
668
672
  | `set_secret` | Create or overwrite a single secret with optional TTL, per-env state, tags, rotation format |
669
673
  | `delete_secret` | Permanently remove a secret value (not undoable from q-ring) |
670
674
  | `has_secret` | Boolean existence check that respects decay (no audit read) |
671
- | `export_secrets` | Render multiple secrets as `.env` or JSON for one-off export |
672
- | `import_dotenv` | Parse `.env` text and bulk-store every key/value pair |
675
+ | `export_secrets` | Render multiple secrets as `.env` or JSON for one-off export (skips approval-protected keys without a grant) |
676
+ | `import_dotenv` | Parse `.env` text and bulk-store every key/value pair (accepts raw content only — never reads files) |
673
677
  | `check_project` | Compare `.q-ring.json` manifest against the keyring for missing/expired/stale keys |
674
678
  | `env_generate` | Render a complete `.env` body from the project manifest, with warnings for gaps |
675
679
 
@@ -741,7 +745,7 @@ q-ring includes a full MCP server with 44 tools for AI agent integration.
741
745
  | `verify_audit_chain` | Recompute the audit hash chain and report the first break point if tampered |
742
746
  | `export_audit` | Export audit events as jsonl, json, or csv for archival/SIEM |
743
747
  | `health_check` | Read-only scope sweep: decay/stale/expired counts plus current anomalies |
744
- | `status_dashboard` | Start a local SSE dashboard with live KPIs, secrets, hooks, and audit feed |
748
+ | `status_dashboard` | Start a local SSE dashboard with live KPIs, secrets, hooks, and audit feed (returns a token-gated `127.0.0.1` URL) |
745
749
  | `agent_scan` | Multi-project health pass with optional `autoRotate` for expired secrets |
746
750
 
747
751
  ### Governance & Policy Tools
@@ -555,87 +555,11 @@ import { randomUUID } from "crypto";
555
555
  // src/utils/http-request.ts
556
556
  import { request as httpsRequest } from "https";
557
557
  import { request as httpRequestPlain } from "http";
558
- var DEFAULT_TIMEOUT_MS = 1e4;
559
- var DEFAULT_MAX_RESPONSE_BYTES = 65536;
560
- function httpRequest(opts) {
561
- const {
562
- url,
563
- method = "GET",
564
- headers = {},
565
- body,
566
- timeoutMs = DEFAULT_TIMEOUT_MS,
567
- maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES
568
- } = opts;
569
- return new Promise((resolve, reject) => {
570
- const parsed = new URL(url);
571
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
572
- reject(new Error(`Unsupported URL protocol: ${parsed.protocol}`));
573
- return;
574
- }
575
- const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequestPlain;
576
- const reqHeaders = { ...headers };
577
- if (body && !reqHeaders["Content-Length"]) {
578
- reqHeaders["Content-Length"] = Buffer.byteLength(body);
579
- }
580
- const req = reqFn(
581
- url,
582
- { method, headers: reqHeaders, timeout: timeoutMs },
583
- (res) => {
584
- const chunks = [];
585
- let totalBytes = 0;
586
- let truncated = false;
587
- res.on("data", (chunk) => {
588
- totalBytes += chunk.length;
589
- if (totalBytes > maxResponseBytes) {
590
- truncated = true;
591
- res.destroy();
592
- return;
593
- }
594
- chunks.push(chunk);
595
- });
596
- let settled = false;
597
- const settle = (result) => {
598
- if (!settled) {
599
- settled = true;
600
- resolve(result);
601
- }
602
- };
603
- const fail = (err) => {
604
- if (!settled) {
605
- settled = true;
606
- reject(err);
607
- }
608
- };
609
- res.on("error", (err) => fail(new Error(`Response error: ${err.message}`)));
610
- res.on("end", () => {
611
- settle({
612
- statusCode: res.statusCode ?? 0,
613
- body: Buffer.concat(chunks).toString("utf8"),
614
- truncated
615
- });
616
- });
617
- res.on("close", () => {
618
- settle({
619
- statusCode: res.statusCode ?? 0,
620
- body: Buffer.concat(chunks).toString("utf8"),
621
- truncated
622
- });
623
- });
624
- }
625
- );
626
- req.on("error", (err) => reject(new Error(`Network error: ${err.message}`)));
627
- req.on("timeout", () => {
628
- req.destroy();
629
- reject(new Error("Request timed out"));
630
- });
631
- if (body) req.write(body);
632
- req.end();
633
- });
634
- }
635
558
 
636
559
  // src/core/ssrf.ts
637
560
  import { lookup } from "dns/promises";
638
561
  import * as dns from "dns";
562
+ import { lookup as dnsLookup } from "dns";
639
563
  import { isIPv4, isIPv6 } from "net";
640
564
  function lookupAddressesSync(hostname2) {
641
565
  const lookupSync2 = dns.lookupSync;
@@ -682,6 +606,36 @@ async function checkSSRF(url) {
682
606
  }
683
607
  return null;
684
608
  }
609
+ function guardedLookup(hostname2, options, callback) {
610
+ if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") {
611
+ dnsLookup(
612
+ hostname2,
613
+ options,
614
+ callback
615
+ );
616
+ return;
617
+ }
618
+ dnsLookup(
619
+ hostname2,
620
+ options,
621
+ (err, address, family) => {
622
+ if (err) return callback(err, address, family);
623
+ const list = Array.isArray(address) ? address : [{ address, family: family ?? 0 }];
624
+ for (const a of list) {
625
+ if (isPrivateIP(a.address)) {
626
+ const blocked = Object.assign(
627
+ new Error(
628
+ `Blocked: "${hostname2}" resolved to private address ${a.address} at connect time.`
629
+ ),
630
+ { code: "EQRINGSSRF" }
631
+ );
632
+ return callback(blocked, address, family);
633
+ }
634
+ }
635
+ callback(null, address, family);
636
+ }
637
+ );
638
+ }
685
639
  function checkJitHttpProvisionUrl(url) {
686
640
  if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
687
641
  try {
@@ -719,6 +673,91 @@ function checkJitHttpProvisionUrl(url) {
719
673
  return null;
720
674
  }
721
675
 
676
+ // src/utils/http-request.ts
677
+ var DEFAULT_TIMEOUT_MS = 1e4;
678
+ var DEFAULT_MAX_RESPONSE_BYTES = 65536;
679
+ function httpRequest(opts) {
680
+ const {
681
+ url,
682
+ method = "GET",
683
+ headers = {},
684
+ body,
685
+ timeoutMs = DEFAULT_TIMEOUT_MS,
686
+ maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES
687
+ } = opts;
688
+ return new Promise((resolve, reject) => {
689
+ const parsed = new URL(url);
690
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
691
+ reject(new Error(`Unsupported URL protocol: ${parsed.protocol}`));
692
+ return;
693
+ }
694
+ const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequestPlain;
695
+ const reqHeaders = { ...headers };
696
+ if (body && !reqHeaders["Content-Length"]) {
697
+ reqHeaders["Content-Length"] = Buffer.byteLength(body);
698
+ }
699
+ const req = reqFn(
700
+ url,
701
+ {
702
+ method,
703
+ headers: reqHeaders,
704
+ timeout: timeoutMs,
705
+ // Re-validate the resolved IP at connect time (DNS-rebinding guard).
706
+ lookup: guardedLookup
707
+ },
708
+ (res) => {
709
+ const chunks = [];
710
+ let totalBytes = 0;
711
+ let truncated = false;
712
+ res.on("data", (chunk) => {
713
+ totalBytes += chunk.length;
714
+ if (totalBytes > maxResponseBytes) {
715
+ truncated = true;
716
+ res.destroy();
717
+ return;
718
+ }
719
+ chunks.push(chunk);
720
+ });
721
+ let settled = false;
722
+ const settle = (result) => {
723
+ if (!settled) {
724
+ settled = true;
725
+ resolve(result);
726
+ }
727
+ };
728
+ const fail = (err) => {
729
+ if (!settled) {
730
+ settled = true;
731
+ reject(err);
732
+ }
733
+ };
734
+ res.on("error", (err) => fail(new Error(`Response error: ${err.message}`)));
735
+ res.on("end", () => {
736
+ settle({
737
+ statusCode: res.statusCode ?? 0,
738
+ body: Buffer.concat(chunks).toString("utf8"),
739
+ truncated
740
+ });
741
+ });
742
+ res.on("close", () => {
743
+ settle({
744
+ statusCode: res.statusCode ?? 0,
745
+ body: Buffer.concat(chunks).toString("utf8"),
746
+ truncated
747
+ });
748
+ });
749
+ }
750
+ );
751
+ req.on("error", (err) => reject(new Error(`Network error: ${err.message}`)));
752
+ req.on("timeout", () => {
753
+ req.destroy();
754
+ reject(new Error("Request timed out"));
755
+ });
756
+ if (body) req.write(body);
757
+ req.end();
758
+ });
759
+ }
760
+
722
761
  // src/core/hooks.ts
723
762
  function getRegistryPath2() {
724
763
  const dir = join5(homedir3(), ".config", "q-ring");
@@ -1128,15 +1167,29 @@ function listApprovals() {
1128
1167
  }
1129
1168
 
1130
1169
  // src/core/policy.ts
1170
+ import { statSync as statSync2 } from "fs";
1171
+ import { join as join7 } from "path";
1131
1172
  var cachedPolicy = null;
1173
+ var policyRoot = null;
1174
+ function resolvePolicyPath(projectPath) {
1175
+ return policyRoot ?? projectPath ?? process.cwd();
1176
+ }
1177
+ function configMtime(pp) {
1178
+ try {
1179
+ return statSync2(join7(pp, ".q-ring.json")).mtimeMs;
1180
+ } catch {
1181
+ return 0;
1182
+ }
1183
+ }
1132
1184
  function loadPolicy(projectPath) {
1133
- const pp = projectPath ?? process.cwd();
1134
- if (cachedPolicy && cachedPolicy.path === pp) {
1185
+ const pp = resolvePolicyPath(projectPath);
1186
+ const mtimeMs = configMtime(pp);
1187
+ if (cachedPolicy && cachedPolicy.path === pp && cachedPolicy.mtimeMs === mtimeMs) {
1135
1188
  return cachedPolicy.policy;
1136
1189
  }
1137
1190
  const config = readProjectConfig(pp);
1138
1191
  const policy = config?.policy ?? {};
1139
- cachedPolicy = { path: pp, policy };
1192
+ cachedPolicy = { path: pp, mtimeMs, policy };
1140
1193
  return policy;
1141
1194
  }
1142
1195
  function checkSecretLifecyclePolicy(input, projectPath) {
@@ -1205,7 +1258,13 @@ function checkExecPolicy(command, projectPath) {
1205
1258
  const policy = loadPolicy(projectPath);
1206
1259
  if (!policy.exec) return { allowed: true, policySource: "no-policy" };
1207
1260
  if (policy.exec.denyCommands) {
1208
- const denied = policy.exec.denyCommands.find((d) => command.includes(d));
1261
+ const denied = policy.exec.denyCommands.find((d) => {
1262
+ const pattern = new RegExp(
1263
+ `(^|[\\s/])${d.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`,
1264
+ "i"
1265
+ );
1266
+ return pattern.test(command);
1267
+ });
1209
1268
  if (denied) {
1210
1269
  return {
1211
1270
  allowed: false,
@@ -1215,7 +1274,8 @@ function checkExecPolicy(command, projectPath) {
1215
1274
  }
1216
1275
  }
1217
1276
  if (policy.exec.allowCommands) {
1218
- const allowed = policy.exec.allowCommands.some((a) => command.startsWith(a));
1277
+ const normalized = command.trimStart();
1278
+ const allowed = policy.exec.allowCommands.some((a) => normalized.startsWith(a));
1219
1279
  if (!allowed) {
1220
1280
  return {
1221
1281
  allowed: false,
@@ -1242,7 +1302,7 @@ function getPolicySummary(projectPath) {
1242
1302
  // src/core/keyring.ts
1243
1303
  import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, unlinkSync } from "fs";
1244
1304
  import { homedir as homedir5 } from "os";
1245
- import { join as join7 } from "path";
1305
+ import { join as join8 } from "path";
1246
1306
  import { Entry, findCredentials } from "@napi-rs/keyring";
1247
1307
 
1248
1308
  // src/utils/hash.ts
@@ -1446,10 +1506,10 @@ registry.register(httpProvider);
1446
1506
 
1447
1507
  // src/core/keyring.ts
1448
1508
  function withJitEnvelopeLock(service, key, fn) {
1449
- const dir = join7(homedir5(), ".config", "q-ring", "jit-locks");
1509
+ const dir = join8(homedir5(), ".config", "q-ring", "jit-locks");
1450
1510
  mkdirSync5(dir, { recursive: true });
1451
1511
  const safe = Buffer.from(`${service}\0${key}`, "utf8").toString("base64url");
1452
- const lockPath = join7(dir, `${safe}.lock`);
1512
+ const lockPath = join8(dir, `${safe}.lock`);
1453
1513
  const deadline = Date.now() + 8e3;
1454
1514
  while (Date.now() < deadline) {
1455
1515
  try {
@@ -1833,6 +1893,16 @@ function exportSecrets(opts = {}) {
1833
1893
  if (entry.envelope) {
1834
1894
  const decay = checkDecay(entry.envelope);
1835
1895
  if (decay.isExpired) continue;
1896
+ if (source === "mcp" && entry.envelope.meta.requiresApproval && !hasApproval(entry.key, entry.scope)) {
1897
+ logAudit({
1898
+ action: "read",
1899
+ key: entry.key,
1900
+ scope: entry.scope,
1901
+ source,
1902
+ detail: "blocked: requires user approval (export)"
1903
+ });
1904
+ continue;
1905
+ }
1836
1906
  const value = collapseValue(entry.envelope, env);
1837
1907
  if (value !== null) {
1838
1908
  rawValues.set(entry.key, value);
@@ -1964,7 +2034,7 @@ function tunnelList() {
1964
2034
 
1965
2035
  // src/core/memory.ts
1966
2036
  import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5, mkdirSync as mkdirSync6 } from "fs";
1967
- import { join as join8 } from "path";
2037
+ import { join as join9 } from "path";
1968
2038
  import { homedir as homedir6, hostname, userInfo } from "os";
1969
2039
  import { createCipheriv, createDecipheriv, createHash as createHash3, randomBytes as randomBytes3 } from "crypto";
1970
2040
  import { Entry as Entry2 } from "@napi-rs/keyring";
@@ -1972,14 +2042,14 @@ var MEMORY_FILE = "agent-memory.enc";
1972
2042
  var KEYRING_SERVICE = "qring-memory-key";
1973
2043
  var KEYRING_ACCOUNT = "encryption-key";
1974
2044
  function getMemoryDir() {
1975
- const dir = join8(homedir6(), ".config", "q-ring");
2045
+ const dir = join9(homedir6(), ".config", "q-ring");
1976
2046
  if (!existsSync6(dir)) {
1977
2047
  mkdirSync6(dir, { recursive: true });
1978
2048
  }
1979
2049
  return dir;
1980
2050
  }
1981
2051
  function getMemoryPath() {
1982
- return join8(getMemoryDir(), MEMORY_FILE);
2052
+ return join9(getMemoryDir(), MEMORY_FILE);
1983
2053
  }
1984
2054
  function deriveLegacyKey() {
1985
2055
  const fingerprint = `qring-memory:${hostname()}:${userInfo().username}`;
@@ -2090,8 +2160,8 @@ export {
2090
2160
  exportAudit,
2091
2161
  detectAnomalies,
2092
2162
  listEntanglements,
2093
- httpRequest,
2094
2163
  checkSSRF,
2164
+ httpRequest,
2095
2165
  registerHook,
2096
2166
  removeHook,
2097
2167
  listHooks,
@@ -2124,4 +2194,4 @@ export {
2124
2194
  forget,
2125
2195
  clearMemory
2126
2196
  };
2127
- //# sourceMappingURL=chunk-A4RZVP3P.js.map
2197
+ //# sourceMappingURL=chunk-5TQNDJT3.js.map