@i4ctime/q-ring 0.16.0 → 0.16.2

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
@@ -518,6 +518,8 @@ qring audit --action canary
518
518
 
519
519
  Values are CSPRNG noise in the provider's real token shape (an `aws` canary matches `AKIA[A-Z0-9]{16}`) — plausible enough to be taken, never valid. Alerts are throttled to one per key per 30 seconds; the audit trail records every read.
520
520
 
521
+ Canaries are built to stay covert: they carry no identifying description (add an innocuous cover story with `--description` if you like), their flag never appears in MCP tool responses, and trip records are visible only from the operator's terminal — never to agents via MCP audit tools. Bulk `export` and `delete` trip them just like reads, so sweeping the ring or removing the tripwire both ring the bell. Done with one? `qring canary disarm <key>` turns it back into an ordinary secret (`qring set` over a canary warns you first — the flag deliberately survives overwrites so an agent can't launder it away).
522
+
521
523
  ### MCP Airlock
522
524
 
523
525
  Run a third-party MCP server behind q-ring. The airlock sits between your agent host and the wrapped server, spawns it with a **stripped environment** (no inherited API keys — opt back in with `--inherit-env`), and records every tool call that crosses it as a `wrap` event in the audit chain, grouped per session and labeled with the calling client's identity. Tool arguments are never logged — they may contain secrets.
@@ -533,7 +535,9 @@ Run a third-party MCP server behind q-ring. The airlock sits between your agent
533
535
  }
534
536
  ```
535
537
 
536
- Tools-only proxy today: `tools/list` and `tools/call` pass through verbatim, so the wrapped server behaves identically — it just can't read your environment, and everything it's asked to do is on the record.
538
+ Tools-only proxy today: `tools/list` and `tools/call` pass through verbatim (pagination, progress notifications, cancellation, and `tools/list_changed` included; long-running tools are governed by the host's own timeout, with a generous airlock ceiling configurable via `QRING_WRAP_TIMEOUT_MS`). A wrapped server's *resources and prompts* are not proxied yet — a resources-heavy server will look tools-only behind the airlock.
539
+
540
+ Be clear about what the airlock is: env stripping plus a tamper-evident record of every tool call. It is **not a sandbox** — the wrapped process still runs as your user with normal filesystem, network, and OS-keychain access, and tool descriptions/results pass through uninspected. See `docs/threat-model.md` for the honest boundary picture.
537
541
 
538
542
  ### Just-In-Time (JIT) Provisioning
539
543
 
@@ -683,7 +687,7 @@ qring exec -- echo "hello"
683
687
 
684
688
  ### Tamper-Evident Audit
685
689
 
686
- Every audit event includes a SHA-256 hash of the previous event, creating a tamper-evident chain. Since v0.14 the chain is also anchored with a keyed HMAC stored in the OS keyring, so `qring audit:verify` detects truncation and whole-file rewrites — not just in-place edits. Verify integrity and export logs in multiple formats. Events from MCP sessions are additionally stamped with the connecting client's self-reported identity (`clientInfo` name@version) — an audit label for "which agent did this", never an authorization boundary, since clients choose what to report.
690
+ Every audit event includes a SHA-256 hash of the previous event, creating a tamper-evident chain. Since v0.14 the chain is also anchored with a keyed HMAC stored in the OS keyring, so `qring audit:verify` detects truncation and whole-file rewrites — not just in-place edits. Verify integrity and export logs in multiple formats. Events from MCP sessions are additionally stamped with the connecting client's self-reported identity (`clientInfo` name@version) — shown in `qring audit` output and filterable with `qring audit --agent <label>`. It's an audit label for "which agent did this", never an authorization boundary, since clients choose what to report.
687
691
 
688
692
  ```bash
689
693
  # Verify the entire audit chain
@@ -951,6 +955,22 @@ Add to `~/.claude/claude_desktop_config.json`:
951
955
  }
952
956
  ```
953
957
 
958
+ ### VS Code Configuration
959
+
960
+ VS Code speaks MCP natively — add to `.vscode/mcp.json` (note the `servers` key, not `mcpServers`):
961
+
962
+ ```json
963
+ {
964
+ "servers": {
965
+ "q-ring": {
966
+ "command": "qring-mcp"
967
+ }
968
+ }
969
+ }
970
+ ```
971
+
972
+ `qring setup` does not write this file yet — VS Code is config-only (no first-party plugin bundle).
973
+
954
974
  ## Editor Plugins
955
975
 
956
976
  The q-ring repo ships three first-party editor packs — each one adds rules/steering, agents, commands, skills, hooks, and the MCP connector to its host editor.
@@ -335,7 +335,8 @@ function isProcessAlive(pid) {
335
335
  }
336
336
  }
337
337
  function withFileLock(name, fn, opts = {}) {
338
- const lockDir = join3(homedir(), ".config", "q-ring", opts.dir ?? "locks");
338
+ const baseDir = process.env.QRING_LOCK_DIR ?? (process.env.QRING_AUDIT_DIR ? join3(process.env.QRING_AUDIT_DIR, ".locks") : join3(homedir(), ".config", "q-ring"));
339
+ const lockDir = join3(baseDir, opts.dir ?? "locks");
339
340
  mkdirSync(lockDir, { recursive: true, mode: 448 });
340
341
  const safe = Buffer.from(name, "utf8").toString("base64url");
341
342
  const lockPath = join3(lockDir, `${safe}.lock`);
@@ -577,6 +578,13 @@ function setAuditAgentLabel(label) {
577
578
  function getAuditAgentLabel() {
578
579
  return auditAgentLabel;
579
580
  }
581
+ function sanitizeAuditText(value, max) {
582
+ const cleaned = Array.from(value).filter((ch) => {
583
+ const cp = ch.codePointAt(0) ?? 0;
584
+ return cp >= 32 && cp !== 127;
585
+ }).join("");
586
+ return cleaned.length > max ? `${cleaned.slice(0, max)}\u2026` : cleaned;
587
+ }
580
588
  function getAuditDir() {
581
589
  if (process.env.QRING_AUDIT_DIR) {
582
590
  if (!existsSync2(process.env.QRING_AUDIT_DIR)) {
@@ -631,6 +639,10 @@ function logAudit(event) {
631
639
  if (full.agent === void 0 && auditAgentLabel) {
632
640
  full.agent = auditAgentLabel;
633
641
  }
642
+ if (full.key !== void 0) full.key = sanitizeAuditText(full.key, 256);
643
+ if (full.detail !== void 0) full.detail = sanitizeAuditText(full.detail, 600);
644
+ if (full.scope !== void 0) full.scope = sanitizeAuditText(full.scope, 128);
645
+ if (full.env !== void 0) full.env = sanitizeAuditText(full.env, 128);
634
646
  const line = JSON.stringify(full);
635
647
  const path = getAuditPath();
636
648
  appendFileSync(path, line + "\n", { mode: 384 });
@@ -643,7 +655,12 @@ function logAudit(event) {
643
655
  },
644
656
  { timeoutMs: 5e3 }
645
657
  );
646
- } catch {
658
+ } catch (err) {
659
+ if (process.env.QRING_DEBUG) {
660
+ console.error(
661
+ `q-ring: audit event dropped (${event.action}${event.key ? ` ${event.key}` : ""}): ${err instanceof Error ? err.message : String(err)}`
662
+ );
663
+ }
647
664
  }
648
665
  }
649
666
  var MAX_AUDIT_BYTES = 12 * 1024 * 1024;
@@ -762,13 +779,33 @@ function exportAudit(opts = {}) {
762
779
  const until = new Date(opts.until).getTime();
763
780
  events = events.filter((e) => new Date(e.timestamp).getTime() <= until);
764
781
  }
782
+ if (opts.excludeActions?.length) {
783
+ const excluded = new Set(opts.excludeActions);
784
+ events = events.filter((e) => !excluded.has(e.action));
785
+ }
765
786
  if (opts.format === "json") {
766
787
  return JSON.stringify(events, null, 2);
767
788
  }
768
789
  if (opts.format === "csv") {
790
+ const csvField = (v) => {
791
+ let s = v === void 0 ? "" : String(v);
792
+ if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
793
+ return `"${s.replace(/"/g, '""')}"`;
794
+ };
769
795
  const header = "timestamp,action,key,scope,env,source,agent,pid,correlationId,detail";
770
796
  const rows = events.map(
771
- (e) => `${e.timestamp},${e.action},${e.key ?? ""},${e.scope ?? ""},${e.env ?? ""},${e.source},${(e.agent ?? "").replace(/,/g, ";")},${e.pid},${e.correlationId ?? ""},${(e.detail ?? "").replace(/,/g, ";")}`
797
+ (e) => [
798
+ csvField(e.timestamp),
799
+ csvField(e.action),
800
+ csvField(e.key),
801
+ csvField(e.scope),
802
+ csvField(e.env),
803
+ csvField(e.source),
804
+ csvField(e.agent),
805
+ csvField(e.pid),
806
+ csvField(e.correlationId),
807
+ csvField(e.detail)
808
+ ].join(",")
772
809
  );
773
810
  return [header, ...rows].join("\n");
774
811
  }
@@ -1718,7 +1755,8 @@ function notifyUser(title, body) {
1718
1755
  let args;
1719
1756
  if (process.platform === "linux") {
1720
1757
  command = "notify-send";
1721
- args = ["--app-name=q-ring", "--urgency=critical", title, body];
1758
+ const pango = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1759
+ args = ["--app-name=q-ring", "--urgency=critical", pango(title), pango(body)];
1722
1760
  } else if (process.platform === "darwin") {
1723
1761
  command = "osascript";
1724
1762
  const clean = (s) => s.replace(/["\\]/g, "");
@@ -1758,7 +1796,7 @@ function recordCanaryTrip(trip) {
1758
1796
  scope: trip.scope,
1759
1797
  env: trip.env,
1760
1798
  source: trip.source,
1761
- detail: `CANARY TRIPPED: honeytoken read via ${trip.source}`
1799
+ detail: `CANARY TRIPPED: ${trip.detail ?? `honeytoken read via ${trip.source}`}`
1762
1800
  });
1763
1801
  if (!notificationsEnabled()) return;
1764
1802
  const now = Date.now();
@@ -2201,8 +2239,12 @@ function deleteSecret(key, opts = {}) {
2201
2239
  for (const { service, scope } of scopes) {
2202
2240
  const entry = new Entry(service, key);
2203
2241
  try {
2242
+ const existing = readEnvelope(service, key);
2204
2243
  if (entry.deleteCredential()) {
2205
2244
  deleted = true;
2245
+ if (existing?.meta.canary) {
2246
+ recordCanaryTrip({ key, scope, source, detail: "canary deleted" });
2247
+ }
2206
2248
  logAudit({ action: "delete", key, scope, source });
2207
2249
  fireHooks({
2208
2250
  action: "delete",
@@ -2321,6 +2363,9 @@ function exportSecrets(opts = {}) {
2321
2363
  const value = collapseValue(entry.envelope, env);
2322
2364
  if (value !== null) {
2323
2365
  rawValues.set(entry.key, value);
2366
+ if (entry.envelope.meta.canary) {
2367
+ recordCanaryTrip({ key: entry.key, scope: entry.scope, env, source });
2368
+ }
2324
2369
  }
2325
2370
  }
2326
2371
  }
@@ -2333,7 +2378,13 @@ function exportSecrets(opts = {}) {
2333
2378
  console.warn(`Warning: skipped exporting ${key} due to template error: ${err instanceof Error ? err.message : String(err)}`);
2334
2379
  }
2335
2380
  }
2336
- logAudit({ action: "export", source, detail: `format=${format}` });
2381
+ const exportedKeys = [...merged.keys()];
2382
+ const keyList = exportedKeys.length > 20 ? `${exportedKeys.slice(0, 20).join(",")} +${exportedKeys.length - 20} more` : exportedKeys.join(",");
2383
+ logAudit({
2384
+ action: "export",
2385
+ source,
2386
+ detail: `format=${format} keys=${keyList}`
2387
+ });
2337
2388
  if (format === "json") {
2338
2389
  const obj = {};
2339
2390
  for (const [key, value] of merged) {
@@ -2665,4 +2716,4 @@ export {
2665
2716
  listMemory,
2666
2717
  forget
2667
2718
  };
2668
- //# sourceMappingURL=chunk-NCM5GHNW.js.map
2719
+ //# sourceMappingURL=chunk-KFILBHOY.js.map