@i4ctime/q-ring 0.16.2 → 0.17.5

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/dist/index.js CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  PACKAGE_VERSION,
4
+ addCanaryAlert,
4
5
  checkDecay,
5
6
  checkExecPolicy,
6
7
  checkSSRF,
8
+ checkWrapToolPolicy,
7
9
  clearMemory,
8
10
  collapseEnvironment,
9
11
  countLegacyApprovals,
10
12
  deleteSecret,
13
+ describeAlertUrl,
11
14
  detectAnomalies,
12
15
  disableHook,
13
16
  disarmCanary,
@@ -22,10 +25,15 @@ import {
22
25
  getExecMaxRuntime,
23
26
  getPolicySummary,
24
27
  getSecret,
28
+ getWrapRateLimit,
25
29
  grantApproval,
30
+ hasApproval,
26
31
  hasSecret,
32
+ hashProjectPath,
27
33
  httpRequest,
34
+ listAgentSessions,
28
35
  listApprovals,
36
+ listCanaryAlerts,
29
37
  listHooks,
30
38
  listMemory,
31
39
  listSecrets,
@@ -36,17 +44,23 @@ import {
36
44
  registerHook,
37
45
  registry,
38
46
  remember,
47
+ removeCanaryAlert,
39
48
  removeHook,
40
49
  revokeApproval,
50
+ sendCanaryAlerts,
41
51
  serviceForScope,
42
52
  setAuditAgentLabel,
53
+ setCanaryAlertEnabled,
54
+ setPolicyRoot,
43
55
  setSecret,
44
56
  tunnelCreate,
45
57
  tunnelDestroy,
46
58
  tunnelList,
47
59
  tunnelRead,
48
- verifyAuditChain
49
- } from "./chunk-SQKTDYSO.js";
60
+ verifyAuditChain,
61
+ wrapRedactsResults,
62
+ wrapToolRequiresApproval
63
+ } from "./chunk-5ZCQSBVS.js";
50
64
 
51
65
  // src/cli/commands.ts
52
66
  import { Command, Help } from "commander";
@@ -2597,7 +2611,7 @@ function registerToolingCommands(program2) {
2597
2611
  }
2598
2612
  });
2599
2613
  program2.command("status").description("Launch the quantum status dashboard in your browser").option("--port <port>", "Port to serve on", "9876").option("--no-open", "Don't auto-open the browser").action(async (cmd) => {
2600
- const { startDashboardServer } = await import("./dashboard-2MLSJZQR.js");
2614
+ const { startDashboardServer } = await import("./dashboard-ZN3FPR73.js");
2601
2615
  const { exec } = await import("child_process");
2602
2616
  const { platform } = await import("os");
2603
2617
  const port = Number(cmd.port);
@@ -2629,11 +2643,26 @@ ${c.dim(" dashboard stopped")}`);
2629
2643
 
2630
2644
  // src/cli/commands/audit.ts
2631
2645
  import { writeFileSync as writeFileSync3 } from "fs";
2646
+ function parseSince(value) {
2647
+ const m = value.trim().match(/^(\d+)\s*([mhd])$/i);
2648
+ if (m) {
2649
+ const unit = { m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()];
2650
+ return new Date(Date.now() - Number(m[1]) * unit * 1e3).toISOString();
2651
+ }
2652
+ const date = new Date(value);
2653
+ if (Number.isNaN(date.getTime())) {
2654
+ throw new Error(`--since: "${value}" is not an ISO date or a duration like 24h / 7d`);
2655
+ }
2656
+ return date.toISOString();
2657
+ }
2658
+ function fmtSeconds(seconds) {
2659
+ if (seconds < 60) return `${seconds}s`;
2660
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
2661
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
2662
+ return `${Math.floor(seconds / 86400)}d`;
2663
+ }
2632
2664
  function registerAuditCommands(program2) {
2633
- program2.command("audit").description("View the audit log (observer effect)").option("-k, --key <key>", "Filter by key").option(
2634
- "-a, --action <action>",
2635
- "Filter by action (read, write, delete, etc.)"
2636
- ).option("--agent <label>", "Filter by agent label (clientInfo name@version)").option("-n, --limit <n>", "Number of events to show", parseInt, 20).option("--anomalies", "Detect access anomalies").option("--json", "Output as JSON").action((cmd) => {
2665
+ program2.command("audit").description("View the audit log (observer effect)").option("-k, --key <key>", "Filter by key").option("-a, --action <action>", "Filter by action (read, write, delete, etc.)").option("--agent <label>", "Filter by agent label (clientInfo name@version)").option("-n, --limit <n>", "Number of events to show", parseInt, 20).option("--anomalies", "Detect access anomalies").option("--json", "Output as JSON").action((cmd) => {
2637
2666
  if (cmd.anomalies) {
2638
2667
  const anomalies = detectAnomalies(cmd.key);
2639
2668
  if (emitJson(program2, cmd, { anomalies })) return;
@@ -2663,11 +2692,9 @@ ${SYMBOLS.warning} ${c.bold(c.yellow(`${anomalies.length} anomaly/anomalies dete
2663
2692
  console.log(c.dim("No audit events found"));
2664
2693
  return;
2665
2694
  }
2666
- console.log(
2667
- c.bold(`
2695
+ console.log(c.bold(`
2668
2696
  ${SYMBOLS.eye} Audit log (${events.length} events)
2669
- `)
2670
- );
2697
+ `));
2671
2698
  for (const event of events) {
2672
2699
  const ts = new Date(event.timestamp).toLocaleString();
2673
2700
  const actionColor = event.action === "read" ? c.blue : event.action === "write" ? c.green : event.action === "delete" ? c.red : c.yellow;
@@ -2698,13 +2725,9 @@ ${SYMBOLS.warning} ${c.bold(c.yellow(`${anomalies.length} anomaly/anomalies dete
2698
2725
  `${SYMBOLS.shield} ${c.green("Audit chain intact")} \u2014 ${result.totalEvents} events verified`
2699
2726
  );
2700
2727
  } else {
2728
+ console.log(`${SYMBOLS.cross} ${c.red("Audit chain BROKEN")} at event #${result.brokenAt}`);
2701
2729
  console.log(
2702
- `${SYMBOLS.cross} ${c.red("Audit chain BROKEN")} at event #${result.brokenAt}`
2703
- );
2704
- console.log(
2705
- c.dim(
2706
- ` ${result.validEvents}/${result.totalEvents} events valid before break`
2707
- )
2730
+ c.dim(` ${result.validEvents}/${result.totalEvents} events valid before break`)
2708
2731
  );
2709
2732
  if (result.brokenEvent) {
2710
2733
  console.log(
@@ -2729,6 +2752,57 @@ ${SYMBOLS.warning} ${c.bold(c.yellow(`${anomalies.length} anomaly/anomalies dete
2729
2752
  console.log(output);
2730
2753
  }
2731
2754
  });
2755
+ program2.command("audit:sessions").description("Audit activity folded into per-agent sessions (who did what, per MCP client)").option("--agent <label>", "Only sessions for this agent label (clientInfo name@version)").option("--since <when>", "ISO date, or a duration like 24h / 7d (default: 7d)").option("-n, --limit <n>", "Max sessions to show", parseInt, 20).option("-v, --verbose", "Print each session's event lines").option("--json", "Output as JSON").action((cmd) => {
2756
+ const sessions = listAgentSessions({
2757
+ agent: cmd.agent,
2758
+ since: parseSince(cmd.since ?? "7d"),
2759
+ limit: cmd.limit
2760
+ });
2761
+ if (emitJson(program2, cmd, { sessions })) return;
2762
+ if (sessions.length === 0) {
2763
+ console.log(
2764
+ c.dim(
2765
+ "No agent sessions in range. Sessions appear once an MCP client (Cursor, Claude Code, Kiro, or an airlock) has touched the ring."
2766
+ )
2767
+ );
2768
+ return;
2769
+ }
2770
+ console.log(c.bold(`
2771
+ ${SYMBOLS.eye} Agent sessions (${sessions.length})
2772
+ `));
2773
+ for (const s of sessions) {
2774
+ const started = new Date(s.startedAt);
2775
+ const ended = new Date(s.endedAt);
2776
+ const seconds = Math.max(0, Math.round((ended.getTime() - started.getTime()) / 1e3));
2777
+ const who = s.wrapLabel ? `airlock: ${s.wrapLabel}` : s.agent;
2778
+ const head = [
2779
+ c.cyan(`\u27E8${who}\u27E9`),
2780
+ c.dim(`[${s.source}]`),
2781
+ c.dim(
2782
+ `${started.toLocaleString()} \u2192 ${ended.toLocaleTimeString()} (${fmtSeconds(seconds)})`
2783
+ ),
2784
+ `${s.eventCount} events`,
2785
+ s.denials > 0 ? c.red(`${s.denials} denied`) : c.green("0 denied"),
2786
+ s.countsByAction.canary ? c.red(`${s.countsByAction.canary} canary trips`) : ""
2787
+ ];
2788
+ console.log(` ${head.filter(Boolean).join(" ")}`);
2789
+ console.log(c.dim(` id ${s.id} \xB7 keys: ${s.keys.length ? s.keys.join(", ") : "\u2014"}`));
2790
+ if (cmd.verbose) {
2791
+ for (const e of s.events) {
2792
+ const parts = [
2793
+ c.dim(new Date(e.timestamp).toLocaleTimeString()),
2794
+ (e.action === "policy_deny" || e.action === "canary" ? c.red : c.yellow)(
2795
+ e.action.padEnd(11)
2796
+ ),
2797
+ e.key ? c.bold(e.key) : "",
2798
+ e.detail ? c.dim(e.detail) : ""
2799
+ ];
2800
+ console.log(` ${parts.filter(Boolean).join(" ")}`);
2801
+ }
2802
+ }
2803
+ console.log();
2804
+ }
2805
+ });
2732
2806
  program2.command("health").description("Check the health of all secrets").option("-g, --global", "Check global scope only").option("-p, --project", "Check project scope only").option("--project-path <path>", "Explicit project path").option("--json", "Output as JSON").action((cmd) => {
2733
2807
  const opts = buildOpts(cmd);
2734
2808
  const entries = listSecrets(opts);
@@ -2769,9 +2843,7 @@ ${SYMBOLS.warning} ${c.bold(c.yellow(`${anomalies.length} anomaly/anomalies dete
2769
2843
  ${SYMBOLS.shield} Secret health report
2770
2844
  `));
2771
2845
  for (const key of expiredKeys) {
2772
- console.log(
2773
- ` ${c.red(SYMBOLS.cross)} ${c.bold(key)} ${c.bgRed(c.white(" EXPIRED "))}`
2774
- );
2846
+ console.log(` ${c.red(SYMBOLS.cross)} ${c.bold(key)} ${c.bgRed(c.white(" EXPIRED "))}`);
2775
2847
  }
2776
2848
  for (const s of staleKeys) {
2777
2849
  console.log(
@@ -3797,21 +3869,13 @@ var PUSH_TARGETS = ["github", "vercel", "cloudflare"];
3797
3869
  var TARGETS = {
3798
3870
  github: {
3799
3871
  binary: "gh",
3800
- args: (key, opts) => [
3801
- ["secret", "set", key, ...opts.repo ? ["--repo", opts.repo] : []]
3802
- ],
3872
+ args: (key, opts) => [["secret", "set", key, ...opts.repo ? ["--repo", opts.repo] : []]],
3803
3873
  installHint: "install the GitHub CLI: https://cli.github.com (then `gh auth login`)"
3804
3874
  },
3805
3875
  vercel: {
3806
3876
  binary: "vercel",
3807
3877
  // One invocation per environment — `vercel env add` takes a single target.
3808
- args: (key, opts) => (opts.vercelEnvs ?? ["production"]).map((env) => [
3809
- "env",
3810
- "add",
3811
- key,
3812
- env,
3813
- "--force"
3814
- ]),
3878
+ args: (key, opts) => (opts.vercelEnvs ?? ["production"]).map((env) => ["env", "add", key, env, "--force"]),
3815
3879
  installHint: "install the Vercel CLI: npm i -g vercel (then `vercel link` in the project)"
3816
3880
  },
3817
3881
  cloudflare: {
@@ -3843,7 +3907,8 @@ function pushSecrets(opts) {
3843
3907
  }
3844
3908
  const result = { target: opts.target, pushed: [], failed: [], missing: [] };
3845
3909
  for (const key of keys) {
3846
- const value = resolveRef(
3910
+ const preset = opts.presetValues?.[key];
3911
+ const value = preset !== void 0 ? preset : resolveRef(
3847
3912
  { key, raw: `push:${key}` },
3848
3913
  {
3849
3914
  projectPath: opts.projectPath,
@@ -3884,7 +3949,7 @@ function pushSecrets(opts) {
3884
3949
  key,
3885
3950
  env: opts.env,
3886
3951
  source: opts.source ?? "cli",
3887
- detail: `pushed to ${opts.target}${opts.repo ? ` (${opts.repo})` : ""}`
3952
+ detail: `${opts.canaryKeys?.includes(key) ? "canary honeytoken " : ""}pushed to ${opts.target}${opts.repo ? ` (${opts.repo})` : ""}`
3888
3953
  });
3889
3954
  }
3890
3955
  }
@@ -4043,32 +4108,77 @@ ${SYMBOLS.check} ${prefix}${c.bold(verb)}: ${c.cyan(result.configPath)} ${c.dim(
4043
4108
  }
4044
4109
 
4045
4110
  // src/core/canary.ts
4046
- var ALPHA_UPPER_NUM = () => generateSecret({ format: "alphanumeric", length: 16 }).toUpperCase();
4111
+ import { randomInt as randomInt2 } from "crypto";
4112
+ var ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
4113
+ var UPPER_NUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
4114
+ var BASE64 = ALNUM + "+/";
4115
+ var URLSAFE = ALNUM + "_-";
4116
+ var DIGITS = "0123456789";
4117
+ function pick(charset, length) {
4118
+ let out = "";
4119
+ for (let i = 0; i < length; i++) out += charset[randomInt2(charset.length)];
4120
+ return out;
4121
+ }
4047
4122
  var CANARY_FORMATS = {
4048
4123
  aws: {
4049
4124
  name: "aws",
4050
4125
  description: "AWS access key id (AKIA\u2026)",
4051
- generate: () => `AKIA${ALPHA_UPPER_NUM()}`
4126
+ generate: () => `AKIA${pick(UPPER_NUM, 16)}`
4127
+ },
4128
+ "aws-secret": {
4129
+ name: "aws-secret",
4130
+ description: "AWS secret access key (40-char base64)",
4131
+ generate: () => pick(BASE64, 40)
4052
4132
  },
4053
4133
  github: {
4054
4134
  name: "github",
4055
- description: "GitHub personal access token (ghp_\u2026)",
4056
- generate: () => generateSecret({ format: "api-key", prefix: "ghp_", length: 36 })
4135
+ description: "GitHub classic personal access token (ghp_\u2026)",
4136
+ generate: () => `ghp_${pick(ALNUM, 36)}`
4137
+ },
4138
+ "github-pat": {
4139
+ name: "github-pat",
4140
+ description: "GitHub fine-grained personal access token (github_pat_\u2026)",
4141
+ generate: () => `github_pat_${pick(ALNUM, 22)}_${pick(ALNUM, 59)}`
4057
4142
  },
4058
4143
  openai: {
4059
4144
  name: "openai",
4060
4145
  description: "OpenAI API key (sk-\u2026)",
4061
- generate: () => generateSecret({ format: "api-key", prefix: "sk-", length: 48 })
4146
+ generate: () => `sk-${pick(ALNUM, 48)}`
4147
+ },
4148
+ "openai-project": {
4149
+ name: "openai-project",
4150
+ description: "OpenAI project API key (sk-proj-\u2026)",
4151
+ generate: () => `sk-proj-${pick(URLSAFE, 74)}T3BlbkFJ${pick(URLSAFE, 74)}`
4062
4152
  },
4063
4153
  anthropic: {
4064
4154
  name: "anthropic",
4065
- description: "Anthropic API key (sk-ant-\u2026)",
4066
- generate: () => generateSecret({ format: "api-key", prefix: "sk-ant-api03-", length: 80 })
4155
+ description: "Anthropic API key (sk-ant-api03-\u2026)",
4156
+ generate: () => `sk-ant-api03-${pick(URLSAFE, 91)}AA`
4067
4157
  },
4068
4158
  stripe: {
4069
4159
  name: "stripe",
4070
4160
  description: "Stripe live secret key (sk_live_\u2026)",
4071
- generate: () => generateSecret({ format: "api-key", prefix: "sk_live_", length: 24 })
4161
+ generate: () => `sk_live_${pick(ALNUM, 24)}`
4162
+ },
4163
+ gitlab: {
4164
+ name: "gitlab",
4165
+ description: "GitLab personal access token (glpat-\u2026)",
4166
+ generate: () => `glpat-${pick(URLSAFE, 20)}`
4167
+ },
4168
+ slack: {
4169
+ name: "slack",
4170
+ description: "Slack bot token (xoxb-\u2026)",
4171
+ generate: () => `xoxb-${pick(DIGITS, 12)}-${pick(DIGITS, 13)}-${pick(ALNUM, 24)}`
4172
+ },
4173
+ google: {
4174
+ name: "google",
4175
+ description: "Google API key (AIza\u2026)",
4176
+ generate: () => `AIza${pick(URLSAFE, 35)}`
4177
+ },
4178
+ npm: {
4179
+ name: "npm",
4180
+ description: "npm access token (npm_\u2026)",
4181
+ generate: () => `npm_${pick(ALNUM, 36)}`
4072
4182
  },
4073
4183
  generic: {
4074
4184
  name: "generic",
@@ -4133,27 +4243,65 @@ function registerCanaryCommands(program2) {
4133
4243
  "-f, --format <format>",
4134
4244
  `Token shape to imitate (${Object.keys(CANARY_FORMATS).join(", ")})`,
4135
4245
  DEFAULT_CANARY_FORMAT
4136
- ).option("--value <value>", "Plant this exact value instead of generating one").option("--description <text>", "Cover description (none by default \u2014 no tell)").option("--force", "Overwrite an existing non-canary secret at this key").option("--json", "Output the plant result as JSON").option("-g, --global", "Global scope (default)").option("-p, --project", "Project scope").option("--team <id>", "Team scope").option("--org <id>", "Org scope").option("--project-path <path>", "Project path (defaults to cwd)").action((key, cmd) => {
4246
+ ).option("--value <value>", "Plant this exact value instead of generating one").option("--description <text>", "Cover description (none by default \u2014 no tell)").option("--force", "Overwrite an existing non-canary secret at this key").option(
4247
+ "--push <target>",
4248
+ `Also push the planted value to a deployment platform (${PUSH_TARGETS.join(", ")}) so a leaked CI/deploy environment carries a tripwire`
4249
+ ).option("--repo <owner/name>", "GitHub repository (with --push github)").option("--vercel-env <envs>", "Comma-separated Vercel environments (with --push vercel)").option("--json", "Output the plant result as JSON").option("-g, --global", "Global scope (default)").option("-p, --project", "Project scope").option("--team <id>", "Team scope").option("--org <id>", "Org scope").option("--project-path <path>", "Project path (defaults to cwd)").action((key, cmd) => {
4250
+ const pushTarget = cmd.push;
4251
+ if (pushTarget && !PUSH_TARGETS.includes(pushTarget)) {
4252
+ console.error(
4253
+ c.red(
4254
+ `${SYMBOLS.cross} Unknown push target "${pushTarget}" \u2014 expected one of: ${PUSH_TARGETS.join(", ")}`
4255
+ )
4256
+ );
4257
+ process.exit(1);
4258
+ }
4259
+ const opts = buildOpts(cmd);
4137
4260
  const result = plantCanary(key, {
4138
- ...buildOpts(cmd),
4261
+ ...opts,
4139
4262
  format: cmd.format,
4140
4263
  value: cmd.value,
4141
4264
  description: cmd.description,
4142
4265
  force: cmd.force === true
4143
4266
  });
4144
- if (emitJson(program2, cmd, result)) return;
4267
+ const push = pushTarget ? pushSecrets({
4268
+ target: pushTarget,
4269
+ keys: [key],
4270
+ presetValues: { [key]: result.value },
4271
+ canaryKeys: [key],
4272
+ projectPath: opts.projectPath ?? process.cwd(),
4273
+ repo: cmd.repo,
4274
+ vercelEnvs: cmd.vercelEnv?.split(",").map((e) => e.trim()),
4275
+ source: "cli"
4276
+ }) : void 0;
4277
+ if (emitJson(program2, cmd, { ...result, push })) {
4278
+ if (push && push.failed.length > 0) process.exit(1);
4279
+ return;
4280
+ }
4145
4281
  console.log(
4146
4282
  `${SYMBOLS.sparkle} ${c.yellow("canary planted")} ${c.bold(result.key)} ${c.dim(`(${result.format}, ${result.scope} scope)`)}`
4147
4283
  );
4148
4284
  console.log(c.dim(` value: ${result.value}`));
4149
4285
  console.log(
4150
- c.dim(
4151
- " Reads return this fake value and fire a desktop alert + a 'canary' audit event."
4152
- )
4153
- );
4154
- console.log(
4155
- c.dim(" Watch trips with: qring canary list \xB7 qring audit --action canary")
4286
+ c.dim(" Reads return this fake value and fire a desktop alert + a 'canary' audit event.")
4156
4287
  );
4288
+ console.log(c.dim(" Watch trips with: qring canary list \xB7 qring audit --action canary"));
4289
+ if (push) {
4290
+ if (push.pushed.length > 0) {
4291
+ console.log(
4292
+ `${SYMBOLS.link} ${c.yellow("pushed")} ${c.bold(key)} ${c.dim(`to ${push.target}${cmd.repo ? ` (${cmd.repo})` : ""}`)}`
4293
+ );
4294
+ console.log(
4295
+ c.dim(
4296
+ " Caveat: q-ring only sees reads that go through q-ring. Someone using the leaked value on the platform side is not observable here \u2014 pair it with the provider's own alerting if you need that."
4297
+ )
4298
+ );
4299
+ }
4300
+ for (const { error } of push.failed) {
4301
+ console.error(c.red(`${SYMBOLS.cross} push to ${push.target} failed \u2014 ${error}`));
4302
+ }
4303
+ if (push.failed.length > 0) process.exit(1);
4304
+ }
4157
4305
  });
4158
4306
  canary.command("disarm <key>").description("Clear the canary flag so reads stop alarming (value stays fake)").option("-g, --global", "Global scope").option("-p, --project", "Project scope").option("--team <id>", "Team scope").option("--org <id>", "Org scope").option("--project-path <path>", "Project path (defaults to cwd)").action((key, cmd) => {
4159
4307
  if (disarmCanary2(key, buildOpts(cmd))) {
@@ -4169,7 +4317,11 @@ function registerCanaryCommands(program2) {
4169
4317
  const canaries = listCanaries(buildOpts(cmd));
4170
4318
  if (emitJson(program2, cmd, { canaries })) return;
4171
4319
  if (canaries.length === 0) {
4172
- console.log(c.dim("No canaries planted. Plant one: qring canary plant AWS_SECRET_ACCESS_KEY --format aws"));
4320
+ console.log(
4321
+ c.dim(
4322
+ "No canaries planted. Plant one: qring canary plant AWS_SECRET_ACCESS_KEY --format aws"
4323
+ )
4324
+ );
4173
4325
  return;
4174
4326
  }
4175
4327
  console.log(c.bold(`
@@ -4190,6 +4342,95 @@ function registerCanaryCommands(program2) {
4190
4342
  }
4191
4343
  console.log();
4192
4344
  });
4345
+ const alert = canary.command("alert").description("Webhook channels that receive canary trips (Discord, Slack, ntfy, generic JSON)");
4346
+ alert.command("add").description("Register a webhook channel for canary trips").option("--discord <url>", "Discord webhook URL").option("--slack <url>", "Slack incoming-webhook URL").option("--ntfy <url>", "ntfy topic URL (e.g. https://ntfy.sh/my-topic)").option("--url <url>", "Generic endpoint \u2014 receives a JSON POST").option("--description <text>", "Human-readable label").option("--json", "Output as JSON").action((cmd) => {
4347
+ const picked = [
4348
+ ["discord", cmd.discord],
4349
+ ["slack", cmd.slack],
4350
+ ["ntfy", cmd.ntfy],
4351
+ ["generic", cmd.url]
4352
+ ];
4353
+ const chosen = picked.filter(([, url2]) => typeof url2 === "string");
4354
+ if (chosen.length !== 1) {
4355
+ console.error(
4356
+ c.red(`${SYMBOLS.cross} Specify exactly one of --discord, --slack, --ntfy, or --url`)
4357
+ );
4358
+ process.exit(1);
4359
+ }
4360
+ const [type, url] = chosen[0];
4361
+ const channel = addCanaryAlert({ type, url, description: cmd.description });
4362
+ if (emitJson(program2, cmd, channel)) return;
4363
+ console.log(
4364
+ `${SYMBOLS.check} ${c.green("registered")} canary alert ${c.bold(channel.id)} (${type}) ${c.dim(describeAlertUrl(channel.url))}`
4365
+ );
4366
+ console.log(c.dim(` Send a drill: qring canary alert test ${channel.id}`));
4367
+ });
4368
+ alert.command("list").alias("ls").description("List canary alert channels").option("--json", "Output as JSON").action((cmd) => {
4369
+ const channels = listCanaryAlerts();
4370
+ if (emitJson(program2, cmd, { channels })) return;
4371
+ if (channels.length === 0) {
4372
+ console.log(
4373
+ c.dim("No alert channels. Add one: qring canary alert add --discord <webhook-url>")
4374
+ );
4375
+ return;
4376
+ }
4377
+ console.log(c.bold(`
4378
+ ${SYMBOLS.eye} Canary alert channels (${channels.length})
4379
+ `));
4380
+ for (const ch of channels) {
4381
+ const state = ch.enabled ? c.green("enabled") : c.dim("disabled");
4382
+ const desc = ch.description ? c.dim(` \u2014 ${ch.description}`) : "";
4383
+ console.log(
4384
+ ` ${c.bold(ch.id)} ${ch.type.padEnd(7)} ${state} ${c.dim(describeAlertUrl(ch.url))}${desc}`
4385
+ );
4386
+ }
4387
+ console.log();
4388
+ });
4389
+ alert.command("remove <id>").alias("rm").description("Remove an alert channel").action((id) => {
4390
+ if (removeCanaryAlert(id)) {
4391
+ console.log(`${SYMBOLS.check} ${c.green("removed")} canary alert ${c.bold(id)}`);
4392
+ } else {
4393
+ console.error(c.red(`${SYMBOLS.cross} No alert channel with id "${id}"`));
4394
+ process.exit(1);
4395
+ }
4396
+ });
4397
+ for (const [verb, enabled2] of [
4398
+ ["enable", true],
4399
+ ["disable", false]
4400
+ ]) {
4401
+ alert.command(`${verb} <id>`).description(`${verb === "enable" ? "Enable" : "Disable"} an alert channel`).action((id) => {
4402
+ if (setCanaryAlertEnabled(id, enabled2)) {
4403
+ console.log(`${SYMBOLS.check} ${c.green(`${verb}d`)} canary alert ${c.bold(id)}`);
4404
+ } else {
4405
+ console.error(c.red(`${SYMBOLS.cross} No alert channel with id "${id}"`));
4406
+ process.exit(1);
4407
+ }
4408
+ });
4409
+ }
4410
+ alert.command("test [id]").description("Send a clearly-labelled test message to every enabled channel (or one id)").option("--json", "Output as JSON").action(async (id, cmd) => {
4411
+ const results = await sendCanaryAlerts(
4412
+ {
4413
+ key: "EXAMPLE_CANARY",
4414
+ scope: "global",
4415
+ source: "cli",
4416
+ agent: null,
4417
+ detail: "test message",
4418
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4419
+ test: true
4420
+ },
4421
+ id
4422
+ );
4423
+ if (emitJson(program2, cmd, { results })) return;
4424
+ if (results.length === 0) {
4425
+ console.log(c.dim(id ? `No channel with id "${id}"` : "No enabled alert channels"));
4426
+ process.exit(1);
4427
+ }
4428
+ for (const r of results) {
4429
+ const mark = r.success ? c.green(SYMBOLS.check) : c.red(SYMBOLS.cross);
4430
+ console.log(` ${mark} ${c.bold(r.channelId)} ${r.type} ${c.dim(r.message)}`);
4431
+ }
4432
+ if (results.some((r) => !r.success)) process.exit(1);
4433
+ });
4193
4434
  }
4194
4435
 
4195
4436
  // src/core/wrap.ts
@@ -4198,24 +4439,136 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4198
4439
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4199
4440
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4200
4441
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4442
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4201
4443
  import {
4202
4444
  ListToolsRequestSchema,
4203
4445
  CallToolRequestSchema,
4204
4446
  ToolListChangedNotificationSchema,
4447
+ ListResourcesRequestSchema,
4448
+ ListResourceTemplatesRequestSchema,
4449
+ ReadResourceRequestSchema,
4450
+ SubscribeRequestSchema,
4451
+ UnsubscribeRequestSchema,
4452
+ ResourceListChangedNotificationSchema,
4453
+ ResourceUpdatedNotificationSchema,
4454
+ ListPromptsRequestSchema,
4455
+ GetPromptRequestSchema,
4456
+ PromptListChangedNotificationSchema,
4205
4457
  McpError
4206
4458
  } from "@modelcontextprotocol/sdk/types.js";
4459
+
4460
+ // src/core/wrap-redact.ts
4461
+ var REDACTED = "[QRING:REDACTED]";
4462
+ var REFRESH_MS = 60 * 1e3;
4463
+ var MIN_LENGTH = 6;
4464
+ function collectSecretValues(opts = {}) {
4465
+ const values = /* @__PURE__ */ new Set();
4466
+ for (const entry of listSecrets({ ...opts, source: "cli", silent: true })) {
4467
+ if (entry.envelope && checkDecay(entry.envelope).isExpired) continue;
4468
+ const value = getSecret(entry.key, {
4469
+ scope: entry.scope,
4470
+ projectPath: opts.projectPath,
4471
+ env: opts.env,
4472
+ source: "cli",
4473
+ silent: true
4474
+ });
4475
+ if (value && value.length >= MIN_LENGTH) values.add(value);
4476
+ }
4477
+ return [...values].sort((a, b) => b.length - a.length);
4478
+ }
4479
+ function scrubUnknown(node, scrub) {
4480
+ if (typeof node === "string") return scrub(node);
4481
+ if (Array.isArray(node)) return node.map((n) => scrubUnknown(n, scrub));
4482
+ if (node && typeof node === "object") {
4483
+ const out = {};
4484
+ for (const [k, v] of Object.entries(node)) {
4485
+ out[k] = k === "blob" || k === "data" ? v : scrubUnknown(v, scrub);
4486
+ }
4487
+ return out;
4488
+ }
4489
+ return node;
4490
+ }
4491
+ function createRedactor(opts = {}) {
4492
+ let values = [];
4493
+ let builtAt = 0;
4494
+ const ensure = () => {
4495
+ const now = Date.now();
4496
+ if (now - builtAt < REFRESH_MS) return;
4497
+ try {
4498
+ values = collectSecretValues(opts);
4499
+ } catch {
4500
+ }
4501
+ builtAt = now;
4502
+ };
4503
+ const text = (input) => {
4504
+ ensure();
4505
+ let out = input;
4506
+ for (const v of values) {
4507
+ if (out.includes(v)) out = out.split(v).join(REDACTED);
4508
+ }
4509
+ return out;
4510
+ };
4511
+ return {
4512
+ text,
4513
+ result: (payload) => scrubUnknown(payload, text),
4514
+ invalidate: () => {
4515
+ builtAt = 0;
4516
+ }
4517
+ };
4518
+ }
4519
+ var NOOP_REDACTOR = {
4520
+ text: (s) => s,
4521
+ result: (p) => p,
4522
+ invalidate: () => {
4523
+ }
4524
+ };
4525
+
4526
+ // src/core/wrap.ts
4207
4527
  var DEFAULT_DOWNSTREAM_TIMEOUT_MS = 10 * 60 * 1e3;
4208
4528
  function downstreamTimeoutMs() {
4209
4529
  const raw = Number(process.env.QRING_WRAP_TIMEOUT_MS);
4210
4530
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DOWNSTREAM_TIMEOUT_MS;
4211
4531
  }
4212
- function createAirlockServer(downstream, session) {
4532
+ var WRAP_APPROVAL_SCOPE = "wrap";
4533
+ function wrapApprovalService(projectPath) {
4534
+ return `q-ring:wrap:${hashProjectPath(projectPath)}`;
4535
+ }
4536
+ var RateLimiter = class {
4537
+ calls = /* @__PURE__ */ new Map();
4538
+ /** Record a call; returns false when the window is already full. */
4539
+ take(tool, limit, now = Date.now()) {
4540
+ const windowStart = now - limit.perSeconds * 1e3;
4541
+ const recent = (this.calls.get(tool) ?? []).filter((t) => t > windowStart);
4542
+ if (recent.length >= limit.maxCalls) {
4543
+ this.calls.set(tool, recent);
4544
+ return false;
4545
+ }
4546
+ recent.push(now);
4547
+ this.calls.set(tool, recent);
4548
+ return true;
4549
+ }
4550
+ };
4551
+ function truncate(s, max = 200) {
4552
+ return s.length > max ? `${s.slice(0, max)}\u2026` : s;
4553
+ }
4554
+ function createAirlockServer(downstream, session, options = {}) {
4555
+ const projectPath = options.projectPath ?? process.cwd();
4556
+ const redactor = options.redactor ?? NOOP_REDACTOR;
4557
+ const approvalService = wrapApprovalService(projectPath);
4558
+ const limiter = new RateLimiter();
4213
4559
  const downstreamInfo = downstream.getServerVersion();
4560
+ const dsCaps = downstream.getServerCapabilities() ?? {};
4214
4561
  const name = downstreamInfo ? `${downstreamInfo.name} (q-ring airlock)` : "q-ring-airlock";
4562
+ const capabilities = {};
4563
+ if (dsCaps.tools) capabilities.tools = { listChanged: true };
4564
+ if (dsCaps.resources) {
4565
+ capabilities.resources = { listChanged: true, subscribe: !!dsCaps.resources.subscribe };
4566
+ }
4567
+ if (dsCaps.prompts) capabilities.prompts = { listChanged: true };
4215
4568
  const proxy = new Server(
4216
4569
  { name, version: PACKAGE_VERSION },
4217
4570
  {
4218
- capabilities: { tools: { listChanged: true } },
4571
+ capabilities,
4219
4572
  // Servers ship usage guidance in `instructions`; hosts inject it into
4220
4573
  // the system prompt. Losing it would degrade the wrapped server.
4221
4574
  instructions: downstream.getInstructions()
@@ -4225,64 +4578,171 @@ function createAirlockServer(downstream, session) {
4225
4578
  const info = proxy.getClientVersion();
4226
4579
  if (info) setAuditAgentLabel(`${info.name}@${info.version}`);
4227
4580
  };
4228
- downstream.setNotificationHandler(ToolListChangedNotificationSchema, () => {
4229
- void proxy.sendToolListChanged().catch(() => {
4581
+ const audit = (detail, action = "wrap") => logAudit({ action, source: "mcp", detail, correlationId: session.correlationId });
4582
+ const denied = (toolName, reason) => {
4583
+ audit(`airlock blocked "${toolName}" \u2192 ${session.label}: ${reason}`, "policy_deny");
4584
+ return {
4585
+ content: [{ type: "text", text: `airlock: policy denied: ${reason}` }],
4586
+ isError: true
4587
+ };
4588
+ };
4589
+ const gate = (toolName) => {
4590
+ try {
4591
+ const decision = checkWrapToolPolicy(toolName, projectPath);
4592
+ if (!decision.allowed) return denied(toolName, decision.reason ?? "denied by policy");
4593
+ if (wrapToolRequiresApproval(toolName, projectPath) && !hasApproval(toolName, WRAP_APPROVAL_SCOPE, approvalService)) {
4594
+ return denied(
4595
+ toolName,
4596
+ `wrapped tool "${toolName}" requires operator approval \u2014 run: qring mcp approve ${toolName} --for 3600 --reason "<why>"`
4597
+ );
4598
+ }
4599
+ const limit = getWrapRateLimit(toolName, projectPath);
4600
+ if (limit && !limiter.take(toolName, limit)) {
4601
+ return denied(
4602
+ toolName,
4603
+ `rate limit exceeded for "${toolName}" (${limit.maxCalls} calls per ${limit.perSeconds}s)`
4604
+ );
4605
+ }
4606
+ return null;
4607
+ } catch (err) {
4608
+ return denied(toolName, err instanceof Error ? err.message : String(err));
4609
+ }
4610
+ };
4611
+ const toolAllowed = (toolName) => {
4612
+ try {
4613
+ return checkWrapToolPolicy(toolName, projectPath).allowed;
4614
+ } catch {
4615
+ return false;
4616
+ }
4617
+ };
4618
+ if (dsCaps.tools) registerToolHandlers();
4619
+ if (dsCaps.resources) registerResourceHandlers();
4620
+ if (dsCaps.prompts) registerPromptHandlers();
4621
+ return proxy;
4622
+ function registerToolHandlers() {
4623
+ downstream.setNotificationHandler(ToolListChangedNotificationSchema, () => {
4624
+ void proxy.sendToolListChanged().catch(() => {
4625
+ });
4230
4626
  });
4231
- });
4232
- proxy.setRequestHandler(ListToolsRequestSchema, async (request) => {
4233
- if (!downstream.getServerCapabilities()?.tools) return { tools: [] };
4234
- return downstream.listTools(request.params);
4235
- });
4236
- proxy.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
4237
- const toolName = request.params.name;
4238
- logAudit({
4239
- action: "wrap",
4240
- source: "mcp",
4241
- detail: `tool call "${toolName}" \u2192 ${session.label}`,
4242
- correlationId: session.correlationId
4627
+ proxy.setRequestHandler(ListToolsRequestSchema, async (request) => {
4628
+ const listed = await downstream.listTools(request.params);
4629
+ return { ...listed, tools: listed.tools.filter((t) => toolAllowed(t.name)) };
4630
+ });
4631
+ proxy.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
4632
+ const toolName = request.params.name;
4633
+ const blocked = gate(toolName);
4634
+ if (blocked) return blocked;
4635
+ audit(`tool call "${toolName}" \u2192 ${session.label}`);
4636
+ const hostToken = request.params._meta?.progressToken;
4637
+ const onprogress = hostToken !== void 0 ? (progress) => {
4638
+ void extra.sendNotification({
4639
+ method: "notifications/progress",
4640
+ params: { ...progress, progressToken: hostToken }
4641
+ }).catch(() => {
4642
+ });
4643
+ } : void 0;
4644
+ try {
4645
+ const result = await downstream.callTool(request.params, void 0, {
4646
+ signal: extra.signal,
4647
+ timeout: downstreamTimeoutMs(),
4648
+ resetTimeoutOnProgress: true,
4649
+ onprogress
4650
+ });
4651
+ return redactor.result(result);
4652
+ } catch (err) {
4653
+ if (err instanceof McpError) throw err;
4654
+ const message = err instanceof Error ? err.message : String(err);
4655
+ audit(`tool call "${toolName}" failed: ${message}`);
4656
+ return {
4657
+ content: [{ type: "text", text: `airlock: downstream error: ${message}` }],
4658
+ isError: true
4659
+ };
4660
+ }
4243
4661
  });
4244
- const hostToken = request.params._meta?.progressToken;
4245
- const onprogress = hostToken !== void 0 ? (progress) => {
4246
- void extra.sendNotification({
4247
- method: "notifications/progress",
4248
- params: { ...progress, progressToken: hostToken }
4249
- }).catch(() => {
4662
+ }
4663
+ function registerResourceHandlers() {
4664
+ downstream.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
4665
+ void proxy.sendResourceListChanged().catch(() => {
4250
4666
  });
4251
- } : void 0;
4252
- try {
4253
- return await downstream.callTool(request.params, void 0, {
4254
- signal: extra.signal,
4255
- timeout: downstreamTimeoutMs(),
4256
- resetTimeoutOnProgress: true,
4257
- onprogress
4667
+ });
4668
+ downstream.setNotificationHandler(ResourceUpdatedNotificationSchema, (n) => {
4669
+ void proxy.sendResourceUpdated(n.params).catch(() => {
4258
4670
  });
4259
- } catch (err) {
4260
- if (err instanceof McpError) throw err;
4261
- const message = err instanceof Error ? err.message : String(err);
4262
- logAudit({
4263
- action: "wrap",
4264
- source: "mcp",
4265
- detail: `tool call "${toolName}" failed: ${message}`,
4266
- correlationId: session.correlationId
4671
+ });
4672
+ proxy.setRequestHandler(
4673
+ ListResourcesRequestSchema,
4674
+ (request) => downstream.listResources(request.params)
4675
+ );
4676
+ proxy.setRequestHandler(
4677
+ ListResourceTemplatesRequestSchema,
4678
+ (request) => downstream.listResourceTemplates(request.params)
4679
+ );
4680
+ proxy.setRequestHandler(ReadResourceRequestSchema, async (request, extra) => {
4681
+ audit(`resource read ${truncate(request.params.uri)} \u2192 ${session.label}`);
4682
+ const result = await downstream.readResource(request.params, {
4683
+ signal: extra.signal,
4684
+ timeout: downstreamTimeoutMs()
4267
4685
  });
4268
- return {
4269
- content: [
4270
- { type: "text", text: `airlock: downstream error: ${message}` }
4271
- ],
4272
- isError: true
4273
- };
4686
+ return redactor.result(result);
4687
+ });
4688
+ if (dsCaps.resources?.subscribe) {
4689
+ proxy.setRequestHandler(
4690
+ SubscribeRequestSchema,
4691
+ (request) => downstream.subscribeResource(request.params)
4692
+ );
4693
+ proxy.setRequestHandler(
4694
+ UnsubscribeRequestSchema,
4695
+ (request) => downstream.unsubscribeResource(request.params)
4696
+ );
4274
4697
  }
4275
- });
4276
- return proxy;
4698
+ }
4699
+ function registerPromptHandlers() {
4700
+ downstream.setNotificationHandler(PromptListChangedNotificationSchema, () => {
4701
+ void proxy.sendPromptListChanged().catch(() => {
4702
+ });
4703
+ });
4704
+ proxy.setRequestHandler(
4705
+ ListPromptsRequestSchema,
4706
+ (request) => downstream.listPrompts(request.params)
4707
+ );
4708
+ proxy.setRequestHandler(GetPromptRequestSchema, async (request, extra) => {
4709
+ audit(`prompt get "${request.params.name}" \u2192 ${session.label}`);
4710
+ const result = await downstream.getPrompt(request.params, {
4711
+ signal: extra.signal,
4712
+ timeout: downstreamTimeoutMs()
4713
+ });
4714
+ return redactor.result(result);
4715
+ });
4716
+ }
4717
+ }
4718
+ function parseHeaders(raw) {
4719
+ const headers = {};
4720
+ for (const line of raw ?? []) {
4721
+ const idx = line.indexOf(":");
4722
+ if (idx <= 0) throw new Error(`--header expects "Name: value", got "${line}"`);
4723
+ headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
4724
+ }
4725
+ return headers;
4277
4726
  }
4278
4727
  async function connectDownstream(opts) {
4279
- const client = new Client({
4280
- name: "q-ring-airlock",
4281
- version: PACKAGE_VERSION
4282
- });
4283
- const env = opts.inheritEnv ? Object.fromEntries(
4284
- Object.entries(process.env).filter(([, v]) => v !== void 0)
4285
- ) : void 0;
4728
+ const client = new Client({ name: "q-ring-airlock", version: PACKAGE_VERSION });
4729
+ if (opts.url) {
4730
+ const headers = parseHeaders(opts.headers);
4731
+ if (opts.authSecret) {
4732
+ const value = getSecret(opts.authSecret, { projectPath: opts.projectPath, source: "cli" });
4733
+ if (value === null) {
4734
+ throw new Error(`--auth-secret: "${opts.authSecret}" not found in the keyring`);
4735
+ }
4736
+ headers.Authorization = `Bearer ${value}`;
4737
+ }
4738
+ const transport2 = new StreamableHTTPClientTransport(new URL(opts.url), {
4739
+ requestInit: { headers }
4740
+ });
4741
+ await client.connect(transport2);
4742
+ return client;
4743
+ }
4744
+ if (!opts.command) throw new Error("wrap needs a server command or --url");
4745
+ const env = opts.inheritEnv ? Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== void 0)) : void 0;
4286
4746
  const transport = new StdioClientTransport({
4287
4747
  command: opts.command,
4288
4748
  args: opts.args ?? [],
@@ -4293,27 +4753,34 @@ async function connectDownstream(opts) {
4293
4753
  return client;
4294
4754
  }
4295
4755
  async function runWrap(opts) {
4296
- const label = opts.label ?? [opts.command, ...opts.args ?? []].join(" ");
4756
+ const projectPath = opts.projectPath ?? process.cwd();
4757
+ setPolicyRoot(projectPath);
4758
+ const label = opts.label ?? opts.url ?? [opts.command, ...opts.args ?? []].join(" ");
4297
4759
  const session = { label, correlationId: randomUUID() };
4298
4760
  const downstream = await connectDownstream(opts);
4299
4761
  const downstreamInfo = downstream.getServerVersion();
4300
- if (!downstream.getServerCapabilities()?.tools) {
4762
+ const caps = downstream.getServerCapabilities() ?? {};
4763
+ const surfaces = ["tools", "resources", "prompts"].filter((c2) => caps[c2]);
4764
+ if (surfaces.length === 0) {
4301
4765
  await downstream.close().catch(() => {
4302
4766
  });
4303
4767
  throw new Error(
4304
- `wrapped server "${downstreamInfo?.name ?? label}" exposes no tools capability \u2014 the airlock is tools-only (resources/prompts are not proxied yet)`
4768
+ `wrapped server "${downstreamInfo?.name ?? label}" exposes no tools, resources, or prompts \u2014 nothing to proxy`
4305
4769
  );
4306
4770
  }
4771
+ const redact = opts.redact ?? wrapRedactsResults(projectPath);
4772
+ const redactor = redact ? createRedactor({ projectPath }) : NOOP_REDACTOR;
4773
+ const envNote = opts.url ? "remote (http)" : opts.inheritEnv ? "inherited" : "stripped";
4307
4774
  logAudit({
4308
4775
  action: "wrap",
4309
4776
  source: "cli",
4310
- detail: `airlock session started: ${label}${opts.inheritEnv ? " (env inherited)" : " (env stripped)"}`,
4777
+ detail: `airlock session started: ${label} (env ${envNote}; ${surfaces.join("+")}; results ${redact ? "redacted" : "unredacted"})`,
4311
4778
  correlationId: session.correlationId
4312
4779
  });
4313
4780
  console.error(
4314
- `q-ring airlock: wrapping ${downstreamInfo?.name ?? label} \u2014 env ${opts.inheritEnv ? "inherited" : "stripped"}, tool calls audited (session ${session.correlationId.slice(0, 8)})`
4781
+ `q-ring airlock: wrapping ${downstreamInfo?.name ?? label} \u2014 env ${envNote}, ${surfaces.join("/")} audited, results ${redact ? "redacted" : "NOT redacted"} (session ${session.correlationId.slice(0, 8)})`
4315
4782
  );
4316
- const proxy = createAirlockServer(downstream, session);
4783
+ const proxy = createAirlockServer(downstream, session, { projectPath, redactor });
4317
4784
  const transport = new StdioServerTransport();
4318
4785
  return new Promise((resolve) => {
4319
4786
  let settled = false;
@@ -4352,20 +4819,44 @@ async function runWrap(opts) {
4352
4819
  }
4353
4820
 
4354
4821
  // src/cli/commands/mcp.ts
4822
+ var collect = (value, previous = []) => [...previous, value];
4355
4823
  function registerMcpCommands(program2) {
4356
4824
  const mcp = program2.command("mcp").description("MCP airlock \u2014 run third-party MCP servers behind q-ring");
4357
- mcp.command("wrap <command...>").description(
4358
- "Proxy an MCP server through the airlock: spawned with a stripped env, every tool call audited. Use -- before the server command: qring mcp wrap -- npx some-server"
4825
+ mcp.command("wrap [command...]").description(
4826
+ "Proxy an MCP server through the airlock: spawned with a stripped env (or dialed over HTTP), every tool call / resource read / prompt audited, results scrubbed of known secrets, policy.wrap enforced. Use -- before the server command: qring mcp wrap -- npx some-server"
4827
+ ).option(
4828
+ "--url <url>",
4829
+ "Wrap a remote Streamable HTTP MCP endpoint instead of spawning a command"
4830
+ ).option(
4831
+ "--header <name:value>",
4832
+ "Extra request header for --url (repeatable)",
4833
+ collect,
4834
+ []
4835
+ ).option(
4836
+ "--auth-secret <KEY>",
4837
+ "q-ring key whose value is sent as `Authorization: Bearer \u2026` to --url (audited read)"
4359
4838
  ).option(
4360
4839
  "--inherit-env",
4361
4840
  "Pass the full parent environment to the wrapped server (default: minimal safe env)"
4362
- ).option("--label <label>", "Session label for audit events (default: the command line)").action(async (commandArgs, cmd) => {
4841
+ ).option("--no-redact", "Do not scrub known secret values from results").option("--label <label>", "Session label for audit events (default: the command line / url)").option(
4842
+ "--project-path <path>",
4843
+ "Project whose .q-ring.json policy governs the session (default: cwd)"
4844
+ ).action(async (commandArgs, cmd) => {
4363
4845
  try {
4846
+ if (!cmd.url && commandArgs.length === 0) {
4847
+ throw new Error("give a server command after -- or pass --url");
4848
+ }
4364
4849
  const code = await runWrap({
4365
4850
  command: commandArgs[0],
4366
4851
  args: commandArgs.slice(1),
4852
+ url: cmd.url,
4853
+ headers: cmd.header,
4854
+ authSecret: cmd.authSecret,
4367
4855
  inheritEnv: cmd.inheritEnv === true,
4368
- label: cmd.label
4856
+ // commander turns --no-redact into redact:false; undefined → policy decides
4857
+ redact: cmd.redact === false ? false : void 0,
4858
+ label: cmd.label,
4859
+ projectPath: cmd.projectPath
4369
4860
  });
4370
4861
  process.exit(code);
4371
4862
  } catch (err) {
@@ -4377,6 +4868,59 @@ function registerMcpCommands(program2) {
4377
4868
  process.exit(1);
4378
4869
  }
4379
4870
  });
4871
+ mcp.command("approve <tool>").description(
4872
+ "Grant a time-limited approval for a wrapped tool listed in policy.wrap.approveTools (or revoke it)"
4873
+ ).option("--for <seconds>", "Approval lifetime in seconds", (v) => parseInt(v, 10), 3600).option("--reason <text>", "Why this tool is being approved").option("--revoke", "Revoke an existing approval instead").option("--project-path <path>", "Project the airlock runs in (default: cwd)").action((tool, cmd) => {
4874
+ const projectPath = cmd.projectPath ?? process.cwd();
4875
+ const service = wrapApprovalService(projectPath);
4876
+ if (cmd.revoke) {
4877
+ if (revokeApproval(tool, WRAP_APPROVAL_SCOPE, service)) {
4878
+ console.log(
4879
+ `${SYMBOLS.check} ${c.green("revoked")} airlock approval for ${c.bold(tool)}`
4880
+ );
4881
+ } else {
4882
+ console.error(c.red(`${SYMBOLS.cross} no airlock approval found for "${tool}"`));
4883
+ process.exit(1);
4884
+ }
4885
+ return;
4886
+ }
4887
+ const entry = grantApproval(tool, WRAP_APPROVAL_SCOPE, service, cmd.for, {
4888
+ reason: cmd.reason ?? "manual airlock approval"
4889
+ });
4890
+ console.log(
4891
+ `${SYMBOLS.check} ${c.green("approved")} wrapped tool ${c.bold(tool)} for ${cmd.for}s`
4892
+ );
4893
+ console.log(c.dim(` id=${entry.id} reason="${entry.reason}" expires=${entry.expiresAt}`));
4894
+ console.log(
4895
+ c.dim(
4896
+ " Applies to airlocks launched from this project directory (policy.wrap.approveTools)."
4897
+ )
4898
+ );
4899
+ });
4900
+ mcp.command("approvals").description("List live airlock tool approvals for this project").option("--project-path <path>", "Project the airlock runs in (default: cwd)").option("--json", "Output as JSON").action((cmd) => {
4901
+ const service = wrapApprovalService(cmd.projectPath ?? process.cwd());
4902
+ const entries = listApprovals().filter(
4903
+ (a) => a.scope === WRAP_APPROVAL_SCOPE && a.service === service
4904
+ );
4905
+ if (cmd.json) {
4906
+ console.log(JSON.stringify({ approvals: entries }, null, 2));
4907
+ return;
4908
+ }
4909
+ if (entries.length === 0) {
4910
+ console.log(
4911
+ c.dim(
4912
+ 'No airlock approvals. Grant one: qring mcp approve <tool> --for 3600 --reason "\u2026"'
4913
+ )
4914
+ );
4915
+ return;
4916
+ }
4917
+ for (const a of entries) {
4918
+ const state = a.tampered ? c.red("TAMPERED") : a.valid ? c.green("valid") : c.yellow("expired");
4919
+ console.log(
4920
+ ` ${SYMBOLS.check} ${c.bold(a.key)} ${state} ${c.dim(`expires ${a.expiresAt} \xB7 ${a.reason}`)}`
4921
+ );
4922
+ }
4923
+ });
4380
4924
  }
4381
4925
 
4382
4926
  // src/cli/commands/doctor.ts
@@ -4571,13 +5115,13 @@ function registerDoctorCommand(program2) {
4571
5115
  }
4572
5116
 
4573
5117
  // src/cli/commands/completion.ts
4574
- function collect(cmd) {
5118
+ function collect2(cmd) {
4575
5119
  return {
4576
5120
  name: cmd.name(),
4577
5121
  aliases: cmd.aliases(),
4578
5122
  description: cmd.description(),
4579
5123
  options: cmd.options.map((o) => ({ flag: o.long ?? o.short ?? "", description: o.description ?? "" })).filter((o) => o.flag.startsWith("--")),
4580
- subcommands: cmd.commands.filter((s) => s.name() !== "help").map((s) => collect(s))
5124
+ subcommands: cmd.commands.filter((s) => s.name() !== "help").map((s) => collect2(s))
4581
5125
  };
4582
5126
  }
4583
5127
  var sanitize = (s) => s.replace(/['"`$\\]/g, "").replace(/[:[\]]/g, " ").replace(/\s+/g, " ").trim();
@@ -4668,7 +5212,7 @@ function fishScript(cmds) {
4668
5212
  }
4669
5213
  function registerCompletionCommand(program2) {
4670
5214
  program2.command("completion <shell>").description("Print a shell completion script (bash, zsh, fish)").action((shell) => {
4671
- const cmds = program2.commands.filter((s) => s.name() !== "help" && s.name() !== "completion").map((s) => collect(s));
5215
+ const cmds = program2.commands.filter((s) => s.name() !== "help" && s.name() !== "completion").map((s) => collect2(s));
4672
5216
  if (shell === "bash") process.stdout.write(bashScript(cmds));
4673
5217
  else if (shell === "zsh") process.stdout.write(zshScript(cmds));
4674
5218
  else if (shell === "fish") process.stdout.write(fishScript(cmds));