@i4ctime/q-ring 0.17.0 → 0.17.6

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,37 +4108,91 @@ ${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
+ }
4122
+ function dodgingPlaceholders(generate) {
4123
+ return () => {
4124
+ for (let attempt = 0; attempt < 32; attempt++) {
4125
+ const value = generate();
4126
+ if (!isPlaceholderValue(value)) return value;
4127
+ }
4128
+ throw new Error("canary generator kept producing placeholder-like values");
4129
+ };
4130
+ }
4047
4131
  var CANARY_FORMATS = {
4048
4132
  aws: {
4049
4133
  name: "aws",
4050
4134
  description: "AWS access key id (AKIA\u2026)",
4051
- generate: () => `AKIA${ALPHA_UPPER_NUM()}`
4135
+ generate: dodgingPlaceholders(() => `AKIA${pick(UPPER_NUM, 16)}`)
4136
+ },
4137
+ "aws-secret": {
4138
+ name: "aws-secret",
4139
+ description: "AWS secret access key (40-char base64)",
4140
+ generate: dodgingPlaceholders(() => pick(BASE64, 40))
4052
4141
  },
4053
4142
  github: {
4054
4143
  name: "github",
4055
- description: "GitHub personal access token (ghp_\u2026)",
4056
- generate: () => generateSecret({ format: "api-key", prefix: "ghp_", length: 36 })
4144
+ description: "GitHub classic personal access token (ghp_\u2026)",
4145
+ generate: dodgingPlaceholders(() => `ghp_${pick(ALNUM, 36)}`)
4146
+ },
4147
+ "github-pat": {
4148
+ name: "github-pat",
4149
+ description: "GitHub fine-grained personal access token (github_pat_\u2026)",
4150
+ generate: dodgingPlaceholders(() => `github_pat_${pick(ALNUM, 22)}_${pick(ALNUM, 59)}`)
4057
4151
  },
4058
4152
  openai: {
4059
4153
  name: "openai",
4060
4154
  description: "OpenAI API key (sk-\u2026)",
4061
- generate: () => generateSecret({ format: "api-key", prefix: "sk-", length: 48 })
4155
+ generate: dodgingPlaceholders(() => `sk-${pick(ALNUM, 48)}`)
4156
+ },
4157
+ "openai-project": {
4158
+ name: "openai-project",
4159
+ description: "OpenAI project API key (sk-proj-\u2026)",
4160
+ generate: dodgingPlaceholders(() => `sk-proj-${pick(URLSAFE, 74)}T3BlbkFJ${pick(URLSAFE, 74)}`)
4062
4161
  },
4063
4162
  anthropic: {
4064
4163
  name: "anthropic",
4065
- description: "Anthropic API key (sk-ant-\u2026)",
4066
- generate: () => generateSecret({ format: "api-key", prefix: "sk-ant-api03-", length: 80 })
4164
+ description: "Anthropic API key (sk-ant-api03-\u2026)",
4165
+ generate: dodgingPlaceholders(() => `sk-ant-api03-${pick(URLSAFE, 91)}AA`)
4067
4166
  },
4068
4167
  stripe: {
4069
4168
  name: "stripe",
4070
4169
  description: "Stripe live secret key (sk_live_\u2026)",
4071
- generate: () => generateSecret({ format: "api-key", prefix: "sk_live_", length: 24 })
4170
+ generate: dodgingPlaceholders(() => `sk_live_${pick(ALNUM, 24)}`)
4171
+ },
4172
+ gitlab: {
4173
+ name: "gitlab",
4174
+ description: "GitLab personal access token (glpat-\u2026)",
4175
+ generate: dodgingPlaceholders(() => `glpat-${pick(URLSAFE, 20)}`)
4176
+ },
4177
+ slack: {
4178
+ name: "slack",
4179
+ description: "Slack bot token (xoxb-\u2026)",
4180
+ generate: dodgingPlaceholders(() => `xoxb-${pick(DIGITS, 12)}-${pick(DIGITS, 13)}-${pick(ALNUM, 24)}`)
4181
+ },
4182
+ google: {
4183
+ name: "google",
4184
+ description: "Google API key (AIza\u2026)",
4185
+ generate: dodgingPlaceholders(() => `AIza${pick(URLSAFE, 35)}`)
4186
+ },
4187
+ npm: {
4188
+ name: "npm",
4189
+ description: "npm access token (npm_\u2026)",
4190
+ generate: dodgingPlaceholders(() => `npm_${pick(ALNUM, 36)}`)
4072
4191
  },
4073
4192
  generic: {
4074
4193
  name: "generic",
4075
4194
  description: "Generic high-entropy API key",
4076
- generate: () => generateSecret({ format: "api-key", prefix: "qk_", length: 40 })
4195
+ generate: dodgingPlaceholders(() => generateSecret({ format: "api-key", prefix: "qk_", length: 40 }))
4077
4196
  }
4078
4197
  };
4079
4198
  var DEFAULT_CANARY_FORMAT = "generic";
@@ -4133,27 +4252,65 @@ function registerCanaryCommands(program2) {
4133
4252
  "-f, --format <format>",
4134
4253
  `Token shape to imitate (${Object.keys(CANARY_FORMATS).join(", ")})`,
4135
4254
  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) => {
4255
+ ).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(
4256
+ "--push <target>",
4257
+ `Also push the planted value to a deployment platform (${PUSH_TARGETS.join(", ")}) so a leaked CI/deploy environment carries a tripwire`
4258
+ ).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) => {
4259
+ const pushTarget = cmd.push;
4260
+ if (pushTarget && !PUSH_TARGETS.includes(pushTarget)) {
4261
+ console.error(
4262
+ c.red(
4263
+ `${SYMBOLS.cross} Unknown push target "${pushTarget}" \u2014 expected one of: ${PUSH_TARGETS.join(", ")}`
4264
+ )
4265
+ );
4266
+ process.exit(1);
4267
+ }
4268
+ const opts = buildOpts(cmd);
4137
4269
  const result = plantCanary(key, {
4138
- ...buildOpts(cmd),
4270
+ ...opts,
4139
4271
  format: cmd.format,
4140
4272
  value: cmd.value,
4141
4273
  description: cmd.description,
4142
4274
  force: cmd.force === true
4143
4275
  });
4144
- if (emitJson(program2, cmd, result)) return;
4276
+ const push = pushTarget ? pushSecrets({
4277
+ target: pushTarget,
4278
+ keys: [key],
4279
+ presetValues: { [key]: result.value },
4280
+ canaryKeys: [key],
4281
+ projectPath: opts.projectPath ?? process.cwd(),
4282
+ repo: cmd.repo,
4283
+ vercelEnvs: cmd.vercelEnv?.split(",").map((e) => e.trim()),
4284
+ source: "cli"
4285
+ }) : void 0;
4286
+ if (emitJson(program2, cmd, { ...result, push })) {
4287
+ if (push && push.failed.length > 0) process.exit(1);
4288
+ return;
4289
+ }
4145
4290
  console.log(
4146
4291
  `${SYMBOLS.sparkle} ${c.yellow("canary planted")} ${c.bold(result.key)} ${c.dim(`(${result.format}, ${result.scope} scope)`)}`
4147
4292
  );
4148
4293
  console.log(c.dim(` value: ${result.value}`));
4149
4294
  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")
4295
+ c.dim(" Reads return this fake value and fire a desktop alert + a 'canary' audit event.")
4156
4296
  );
4297
+ console.log(c.dim(" Watch trips with: qring canary list \xB7 qring audit --action canary"));
4298
+ if (push) {
4299
+ if (push.pushed.length > 0) {
4300
+ console.log(
4301
+ `${SYMBOLS.link} ${c.yellow("pushed")} ${c.bold(key)} ${c.dim(`to ${push.target}${cmd.repo ? ` (${cmd.repo})` : ""}`)}`
4302
+ );
4303
+ console.log(
4304
+ c.dim(
4305
+ " 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."
4306
+ )
4307
+ );
4308
+ }
4309
+ for (const { error } of push.failed) {
4310
+ console.error(c.red(`${SYMBOLS.cross} push to ${push.target} failed \u2014 ${error}`));
4311
+ }
4312
+ if (push.failed.length > 0) process.exit(1);
4313
+ }
4157
4314
  });
4158
4315
  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
4316
  if (disarmCanary2(key, buildOpts(cmd))) {
@@ -4169,7 +4326,11 @@ function registerCanaryCommands(program2) {
4169
4326
  const canaries = listCanaries(buildOpts(cmd));
4170
4327
  if (emitJson(program2, cmd, { canaries })) return;
4171
4328
  if (canaries.length === 0) {
4172
- console.log(c.dim("No canaries planted. Plant one: qring canary plant AWS_SECRET_ACCESS_KEY --format aws"));
4329
+ console.log(
4330
+ c.dim(
4331
+ "No canaries planted. Plant one: qring canary plant AWS_SECRET_ACCESS_KEY --format aws"
4332
+ )
4333
+ );
4173
4334
  return;
4174
4335
  }
4175
4336
  console.log(c.bold(`
@@ -4190,6 +4351,95 @@ function registerCanaryCommands(program2) {
4190
4351
  }
4191
4352
  console.log();
4192
4353
  });
4354
+ const alert = canary.command("alert").description("Webhook channels that receive canary trips (Discord, Slack, ntfy, generic JSON)");
4355
+ 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) => {
4356
+ const picked = [
4357
+ ["discord", cmd.discord],
4358
+ ["slack", cmd.slack],
4359
+ ["ntfy", cmd.ntfy],
4360
+ ["generic", cmd.url]
4361
+ ];
4362
+ const chosen = picked.filter(([, url2]) => typeof url2 === "string");
4363
+ if (chosen.length !== 1) {
4364
+ console.error(
4365
+ c.red(`${SYMBOLS.cross} Specify exactly one of --discord, --slack, --ntfy, or --url`)
4366
+ );
4367
+ process.exit(1);
4368
+ }
4369
+ const [type, url] = chosen[0];
4370
+ const channel = addCanaryAlert({ type, url, description: cmd.description });
4371
+ if (emitJson(program2, cmd, channel)) return;
4372
+ console.log(
4373
+ `${SYMBOLS.check} ${c.green("registered")} canary alert ${c.bold(channel.id)} (${type}) ${c.dim(describeAlertUrl(channel.url))}`
4374
+ );
4375
+ console.log(c.dim(` Send a drill: qring canary alert test ${channel.id}`));
4376
+ });
4377
+ alert.command("list").alias("ls").description("List canary alert channels").option("--json", "Output as JSON").action((cmd) => {
4378
+ const channels = listCanaryAlerts();
4379
+ if (emitJson(program2, cmd, { channels })) return;
4380
+ if (channels.length === 0) {
4381
+ console.log(
4382
+ c.dim("No alert channels. Add one: qring canary alert add --discord <webhook-url>")
4383
+ );
4384
+ return;
4385
+ }
4386
+ console.log(c.bold(`
4387
+ ${SYMBOLS.eye} Canary alert channels (${channels.length})
4388
+ `));
4389
+ for (const ch of channels) {
4390
+ const state = ch.enabled ? c.green("enabled") : c.dim("disabled");
4391
+ const desc = ch.description ? c.dim(` \u2014 ${ch.description}`) : "";
4392
+ console.log(
4393
+ ` ${c.bold(ch.id)} ${ch.type.padEnd(7)} ${state} ${c.dim(describeAlertUrl(ch.url))}${desc}`
4394
+ );
4395
+ }
4396
+ console.log();
4397
+ });
4398
+ alert.command("remove <id>").alias("rm").description("Remove an alert channel").action((id) => {
4399
+ if (removeCanaryAlert(id)) {
4400
+ console.log(`${SYMBOLS.check} ${c.green("removed")} canary alert ${c.bold(id)}`);
4401
+ } else {
4402
+ console.error(c.red(`${SYMBOLS.cross} No alert channel with id "${id}"`));
4403
+ process.exit(1);
4404
+ }
4405
+ });
4406
+ for (const [verb, enabled2] of [
4407
+ ["enable", true],
4408
+ ["disable", false]
4409
+ ]) {
4410
+ alert.command(`${verb} <id>`).description(`${verb === "enable" ? "Enable" : "Disable"} an alert channel`).action((id) => {
4411
+ if (setCanaryAlertEnabled(id, enabled2)) {
4412
+ console.log(`${SYMBOLS.check} ${c.green(`${verb}d`)} canary alert ${c.bold(id)}`);
4413
+ } else {
4414
+ console.error(c.red(`${SYMBOLS.cross} No alert channel with id "${id}"`));
4415
+ process.exit(1);
4416
+ }
4417
+ });
4418
+ }
4419
+ 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) => {
4420
+ const results = await sendCanaryAlerts(
4421
+ {
4422
+ key: "EXAMPLE_CANARY",
4423
+ scope: "global",
4424
+ source: "cli",
4425
+ agent: null,
4426
+ detail: "test message",
4427
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4428
+ test: true
4429
+ },
4430
+ id
4431
+ );
4432
+ if (emitJson(program2, cmd, { results })) return;
4433
+ if (results.length === 0) {
4434
+ console.log(c.dim(id ? `No channel with id "${id}"` : "No enabled alert channels"));
4435
+ process.exit(1);
4436
+ }
4437
+ for (const r of results) {
4438
+ const mark = r.success ? c.green(SYMBOLS.check) : c.red(SYMBOLS.cross);
4439
+ console.log(` ${mark} ${c.bold(r.channelId)} ${r.type} ${c.dim(r.message)}`);
4440
+ }
4441
+ if (results.some((r) => !r.success)) process.exit(1);
4442
+ });
4193
4443
  }
4194
4444
 
4195
4445
  // src/core/wrap.ts
@@ -4198,24 +4448,136 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4198
4448
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4199
4449
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4200
4450
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4451
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4201
4452
  import {
4202
4453
  ListToolsRequestSchema,
4203
4454
  CallToolRequestSchema,
4204
4455
  ToolListChangedNotificationSchema,
4456
+ ListResourcesRequestSchema,
4457
+ ListResourceTemplatesRequestSchema,
4458
+ ReadResourceRequestSchema,
4459
+ SubscribeRequestSchema,
4460
+ UnsubscribeRequestSchema,
4461
+ ResourceListChangedNotificationSchema,
4462
+ ResourceUpdatedNotificationSchema,
4463
+ ListPromptsRequestSchema,
4464
+ GetPromptRequestSchema,
4465
+ PromptListChangedNotificationSchema,
4205
4466
  McpError
4206
4467
  } from "@modelcontextprotocol/sdk/types.js";
4468
+
4469
+ // src/core/wrap-redact.ts
4470
+ var REDACTED = "[QRING:REDACTED]";
4471
+ var REFRESH_MS = 60 * 1e3;
4472
+ var MIN_LENGTH = 6;
4473
+ function collectSecretValues(opts = {}) {
4474
+ const values = /* @__PURE__ */ new Set();
4475
+ for (const entry of listSecrets({ ...opts, source: "cli", silent: true })) {
4476
+ if (entry.envelope && checkDecay(entry.envelope).isExpired) continue;
4477
+ const value = getSecret(entry.key, {
4478
+ scope: entry.scope,
4479
+ projectPath: opts.projectPath,
4480
+ env: opts.env,
4481
+ source: "cli",
4482
+ silent: true
4483
+ });
4484
+ if (value && value.length >= MIN_LENGTH) values.add(value);
4485
+ }
4486
+ return [...values].sort((a, b) => b.length - a.length);
4487
+ }
4488
+ function scrubUnknown(node, scrub) {
4489
+ if (typeof node === "string") return scrub(node);
4490
+ if (Array.isArray(node)) return node.map((n) => scrubUnknown(n, scrub));
4491
+ if (node && typeof node === "object") {
4492
+ const out = {};
4493
+ for (const [k, v] of Object.entries(node)) {
4494
+ out[k] = k === "blob" || k === "data" ? v : scrubUnknown(v, scrub);
4495
+ }
4496
+ return out;
4497
+ }
4498
+ return node;
4499
+ }
4500
+ function createRedactor(opts = {}) {
4501
+ let values = [];
4502
+ let builtAt = 0;
4503
+ const ensure = () => {
4504
+ const now = Date.now();
4505
+ if (now - builtAt < REFRESH_MS) return;
4506
+ try {
4507
+ values = collectSecretValues(opts);
4508
+ } catch {
4509
+ }
4510
+ builtAt = now;
4511
+ };
4512
+ const text = (input) => {
4513
+ ensure();
4514
+ let out = input;
4515
+ for (const v of values) {
4516
+ if (out.includes(v)) out = out.split(v).join(REDACTED);
4517
+ }
4518
+ return out;
4519
+ };
4520
+ return {
4521
+ text,
4522
+ result: (payload) => scrubUnknown(payload, text),
4523
+ invalidate: () => {
4524
+ builtAt = 0;
4525
+ }
4526
+ };
4527
+ }
4528
+ var NOOP_REDACTOR = {
4529
+ text: (s) => s,
4530
+ result: (p) => p,
4531
+ invalidate: () => {
4532
+ }
4533
+ };
4534
+
4535
+ // src/core/wrap.ts
4207
4536
  var DEFAULT_DOWNSTREAM_TIMEOUT_MS = 10 * 60 * 1e3;
4208
4537
  function downstreamTimeoutMs() {
4209
4538
  const raw = Number(process.env.QRING_WRAP_TIMEOUT_MS);
4210
4539
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DOWNSTREAM_TIMEOUT_MS;
4211
4540
  }
4212
- function createAirlockServer(downstream, session) {
4541
+ var WRAP_APPROVAL_SCOPE = "wrap";
4542
+ function wrapApprovalService(projectPath) {
4543
+ return `q-ring:wrap:${hashProjectPath(projectPath)}`;
4544
+ }
4545
+ var RateLimiter = class {
4546
+ calls = /* @__PURE__ */ new Map();
4547
+ /** Record a call; returns false when the window is already full. */
4548
+ take(tool, limit, now = Date.now()) {
4549
+ const windowStart = now - limit.perSeconds * 1e3;
4550
+ const recent = (this.calls.get(tool) ?? []).filter((t) => t > windowStart);
4551
+ if (recent.length >= limit.maxCalls) {
4552
+ this.calls.set(tool, recent);
4553
+ return false;
4554
+ }
4555
+ recent.push(now);
4556
+ this.calls.set(tool, recent);
4557
+ return true;
4558
+ }
4559
+ };
4560
+ function truncate(s, max = 200) {
4561
+ return s.length > max ? `${s.slice(0, max)}\u2026` : s;
4562
+ }
4563
+ function createAirlockServer(downstream, session, options = {}) {
4564
+ const projectPath = options.projectPath ?? process.cwd();
4565
+ const redactor = options.redactor ?? NOOP_REDACTOR;
4566
+ const approvalService = wrapApprovalService(projectPath);
4567
+ const limiter = new RateLimiter();
4213
4568
  const downstreamInfo = downstream.getServerVersion();
4569
+ const dsCaps = downstream.getServerCapabilities() ?? {};
4214
4570
  const name = downstreamInfo ? `${downstreamInfo.name} (q-ring airlock)` : "q-ring-airlock";
4571
+ const capabilities = {};
4572
+ if (dsCaps.tools) capabilities.tools = { listChanged: true };
4573
+ if (dsCaps.resources) {
4574
+ capabilities.resources = { listChanged: true, subscribe: !!dsCaps.resources.subscribe };
4575
+ }
4576
+ if (dsCaps.prompts) capabilities.prompts = { listChanged: true };
4215
4577
  const proxy = new Server(
4216
4578
  { name, version: PACKAGE_VERSION },
4217
4579
  {
4218
- capabilities: { tools: { listChanged: true } },
4580
+ capabilities,
4219
4581
  // Servers ship usage guidance in `instructions`; hosts inject it into
4220
4582
  // the system prompt. Losing it would degrade the wrapped server.
4221
4583
  instructions: downstream.getInstructions()
@@ -4225,64 +4587,171 @@ function createAirlockServer(downstream, session) {
4225
4587
  const info = proxy.getClientVersion();
4226
4588
  if (info) setAuditAgentLabel(`${info.name}@${info.version}`);
4227
4589
  };
4228
- downstream.setNotificationHandler(ToolListChangedNotificationSchema, () => {
4229
- void proxy.sendToolListChanged().catch(() => {
4590
+ const audit = (detail, action = "wrap") => logAudit({ action, source: "mcp", detail, correlationId: session.correlationId });
4591
+ const denied = (toolName, reason) => {
4592
+ audit(`airlock blocked "${toolName}" \u2192 ${session.label}: ${reason}`, "policy_deny");
4593
+ return {
4594
+ content: [{ type: "text", text: `airlock: policy denied: ${reason}` }],
4595
+ isError: true
4596
+ };
4597
+ };
4598
+ const gate = (toolName) => {
4599
+ try {
4600
+ const decision = checkWrapToolPolicy(toolName, projectPath);
4601
+ if (!decision.allowed) return denied(toolName, decision.reason ?? "denied by policy");
4602
+ if (wrapToolRequiresApproval(toolName, projectPath) && !hasApproval(toolName, WRAP_APPROVAL_SCOPE, approvalService)) {
4603
+ return denied(
4604
+ toolName,
4605
+ `wrapped tool "${toolName}" requires operator approval \u2014 run: qring mcp approve ${toolName} --for 3600 --reason "<why>"`
4606
+ );
4607
+ }
4608
+ const limit = getWrapRateLimit(toolName, projectPath);
4609
+ if (limit && !limiter.take(toolName, limit)) {
4610
+ return denied(
4611
+ toolName,
4612
+ `rate limit exceeded for "${toolName}" (${limit.maxCalls} calls per ${limit.perSeconds}s)`
4613
+ );
4614
+ }
4615
+ return null;
4616
+ } catch (err) {
4617
+ return denied(toolName, err instanceof Error ? err.message : String(err));
4618
+ }
4619
+ };
4620
+ const toolAllowed = (toolName) => {
4621
+ try {
4622
+ return checkWrapToolPolicy(toolName, projectPath).allowed;
4623
+ } catch {
4624
+ return false;
4625
+ }
4626
+ };
4627
+ if (dsCaps.tools) registerToolHandlers();
4628
+ if (dsCaps.resources) registerResourceHandlers();
4629
+ if (dsCaps.prompts) registerPromptHandlers();
4630
+ return proxy;
4631
+ function registerToolHandlers() {
4632
+ downstream.setNotificationHandler(ToolListChangedNotificationSchema, () => {
4633
+ void proxy.sendToolListChanged().catch(() => {
4634
+ });
4230
4635
  });
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
4636
+ proxy.setRequestHandler(ListToolsRequestSchema, async (request) => {
4637
+ const listed = await downstream.listTools(request.params);
4638
+ return { ...listed, tools: listed.tools.filter((t) => toolAllowed(t.name)) };
4639
+ });
4640
+ proxy.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
4641
+ const toolName = request.params.name;
4642
+ const blocked = gate(toolName);
4643
+ if (blocked) return blocked;
4644
+ audit(`tool call "${toolName}" \u2192 ${session.label}`);
4645
+ const hostToken = request.params._meta?.progressToken;
4646
+ const onprogress = hostToken !== void 0 ? (progress) => {
4647
+ void extra.sendNotification({
4648
+ method: "notifications/progress",
4649
+ params: { ...progress, progressToken: hostToken }
4650
+ }).catch(() => {
4651
+ });
4652
+ } : void 0;
4653
+ try {
4654
+ const result = await downstream.callTool(request.params, void 0, {
4655
+ signal: extra.signal,
4656
+ timeout: downstreamTimeoutMs(),
4657
+ resetTimeoutOnProgress: true,
4658
+ onprogress
4659
+ });
4660
+ return redactor.result(result);
4661
+ } catch (err) {
4662
+ if (err instanceof McpError) throw err;
4663
+ const message = err instanceof Error ? err.message : String(err);
4664
+ audit(`tool call "${toolName}" failed: ${message}`);
4665
+ return {
4666
+ content: [{ type: "text", text: `airlock: downstream error: ${message}` }],
4667
+ isError: true
4668
+ };
4669
+ }
4243
4670
  });
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(() => {
4671
+ }
4672
+ function registerResourceHandlers() {
4673
+ downstream.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
4674
+ void proxy.sendResourceListChanged().catch(() => {
4250
4675
  });
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
4676
+ });
4677
+ downstream.setNotificationHandler(ResourceUpdatedNotificationSchema, (n) => {
4678
+ void proxy.sendResourceUpdated(n.params).catch(() => {
4258
4679
  });
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
4680
+ });
4681
+ proxy.setRequestHandler(
4682
+ ListResourcesRequestSchema,
4683
+ (request) => downstream.listResources(request.params)
4684
+ );
4685
+ proxy.setRequestHandler(
4686
+ ListResourceTemplatesRequestSchema,
4687
+ (request) => downstream.listResourceTemplates(request.params)
4688
+ );
4689
+ proxy.setRequestHandler(ReadResourceRequestSchema, async (request, extra) => {
4690
+ audit(`resource read ${truncate(request.params.uri)} \u2192 ${session.label}`);
4691
+ const result = await downstream.readResource(request.params, {
4692
+ signal: extra.signal,
4693
+ timeout: downstreamTimeoutMs()
4267
4694
  });
4268
- return {
4269
- content: [
4270
- { type: "text", text: `airlock: downstream error: ${message}` }
4271
- ],
4272
- isError: true
4273
- };
4695
+ return redactor.result(result);
4696
+ });
4697
+ if (dsCaps.resources?.subscribe) {
4698
+ proxy.setRequestHandler(
4699
+ SubscribeRequestSchema,
4700
+ (request) => downstream.subscribeResource(request.params)
4701
+ );
4702
+ proxy.setRequestHandler(
4703
+ UnsubscribeRequestSchema,
4704
+ (request) => downstream.unsubscribeResource(request.params)
4705
+ );
4274
4706
  }
4275
- });
4276
- return proxy;
4707
+ }
4708
+ function registerPromptHandlers() {
4709
+ downstream.setNotificationHandler(PromptListChangedNotificationSchema, () => {
4710
+ void proxy.sendPromptListChanged().catch(() => {
4711
+ });
4712
+ });
4713
+ proxy.setRequestHandler(
4714
+ ListPromptsRequestSchema,
4715
+ (request) => downstream.listPrompts(request.params)
4716
+ );
4717
+ proxy.setRequestHandler(GetPromptRequestSchema, async (request, extra) => {
4718
+ audit(`prompt get "${request.params.name}" \u2192 ${session.label}`);
4719
+ const result = await downstream.getPrompt(request.params, {
4720
+ signal: extra.signal,
4721
+ timeout: downstreamTimeoutMs()
4722
+ });
4723
+ return redactor.result(result);
4724
+ });
4725
+ }
4726
+ }
4727
+ function parseHeaders(raw) {
4728
+ const headers = {};
4729
+ for (const line of raw ?? []) {
4730
+ const idx = line.indexOf(":");
4731
+ if (idx <= 0) throw new Error(`--header expects "Name: value", got "${line}"`);
4732
+ headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
4733
+ }
4734
+ return headers;
4277
4735
  }
4278
4736
  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;
4737
+ const client = new Client({ name: "q-ring-airlock", version: PACKAGE_VERSION });
4738
+ if (opts.url) {
4739
+ const headers = parseHeaders(opts.headers);
4740
+ if (opts.authSecret) {
4741
+ const value = getSecret(opts.authSecret, { projectPath: opts.projectPath, source: "cli" });
4742
+ if (value === null) {
4743
+ throw new Error(`--auth-secret: "${opts.authSecret}" not found in the keyring`);
4744
+ }
4745
+ headers.Authorization = `Bearer ${value}`;
4746
+ }
4747
+ const transport2 = new StreamableHTTPClientTransport(new URL(opts.url), {
4748
+ requestInit: { headers }
4749
+ });
4750
+ await client.connect(transport2);
4751
+ return client;
4752
+ }
4753
+ if (!opts.command) throw new Error("wrap needs a server command or --url");
4754
+ const env = opts.inheritEnv ? Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== void 0)) : void 0;
4286
4755
  const transport = new StdioClientTransport({
4287
4756
  command: opts.command,
4288
4757
  args: opts.args ?? [],
@@ -4293,27 +4762,34 @@ async function connectDownstream(opts) {
4293
4762
  return client;
4294
4763
  }
4295
4764
  async function runWrap(opts) {
4296
- const label = opts.label ?? [opts.command, ...opts.args ?? []].join(" ");
4765
+ const projectPath = opts.projectPath ?? process.cwd();
4766
+ setPolicyRoot(projectPath);
4767
+ const label = opts.label ?? opts.url ?? [opts.command, ...opts.args ?? []].join(" ");
4297
4768
  const session = { label, correlationId: randomUUID() };
4298
4769
  const downstream = await connectDownstream(opts);
4299
4770
  const downstreamInfo = downstream.getServerVersion();
4300
- if (!downstream.getServerCapabilities()?.tools) {
4771
+ const caps = downstream.getServerCapabilities() ?? {};
4772
+ const surfaces = ["tools", "resources", "prompts"].filter((c2) => caps[c2]);
4773
+ if (surfaces.length === 0) {
4301
4774
  await downstream.close().catch(() => {
4302
4775
  });
4303
4776
  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)`
4777
+ `wrapped server "${downstreamInfo?.name ?? label}" exposes no tools, resources, or prompts \u2014 nothing to proxy`
4305
4778
  );
4306
4779
  }
4780
+ const redact = opts.redact ?? wrapRedactsResults(projectPath);
4781
+ const redactor = redact ? createRedactor({ projectPath }) : NOOP_REDACTOR;
4782
+ const envNote = opts.url ? "remote (http)" : opts.inheritEnv ? "inherited" : "stripped";
4307
4783
  logAudit({
4308
4784
  action: "wrap",
4309
4785
  source: "cli",
4310
- detail: `airlock session started: ${label}${opts.inheritEnv ? " (env inherited)" : " (env stripped)"}`,
4786
+ detail: `airlock session started: ${label} (env ${envNote}; ${surfaces.join("+")}; results ${redact ? "redacted" : "unredacted"})`,
4311
4787
  correlationId: session.correlationId
4312
4788
  });
4313
4789
  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)})`
4790
+ `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
4791
  );
4316
- const proxy = createAirlockServer(downstream, session);
4792
+ const proxy = createAirlockServer(downstream, session, { projectPath, redactor });
4317
4793
  const transport = new StdioServerTransport();
4318
4794
  return new Promise((resolve) => {
4319
4795
  let settled = false;
@@ -4352,20 +4828,44 @@ async function runWrap(opts) {
4352
4828
  }
4353
4829
 
4354
4830
  // src/cli/commands/mcp.ts
4831
+ var collect = (value, previous = []) => [...previous, value];
4355
4832
  function registerMcpCommands(program2) {
4356
4833
  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"
4834
+ mcp.command("wrap [command...]").description(
4835
+ "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"
4836
+ ).option(
4837
+ "--url <url>",
4838
+ "Wrap a remote Streamable HTTP MCP endpoint instead of spawning a command"
4839
+ ).option(
4840
+ "--header <name:value>",
4841
+ "Extra request header for --url (repeatable)",
4842
+ collect,
4843
+ []
4844
+ ).option(
4845
+ "--auth-secret <KEY>",
4846
+ "q-ring key whose value is sent as `Authorization: Bearer \u2026` to --url (audited read)"
4359
4847
  ).option(
4360
4848
  "--inherit-env",
4361
4849
  "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) => {
4850
+ ).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(
4851
+ "--project-path <path>",
4852
+ "Project whose .q-ring.json policy governs the session (default: cwd)"
4853
+ ).action(async (commandArgs, cmd) => {
4363
4854
  try {
4855
+ if (!cmd.url && commandArgs.length === 0) {
4856
+ throw new Error("give a server command after -- or pass --url");
4857
+ }
4364
4858
  const code = await runWrap({
4365
4859
  command: commandArgs[0],
4366
4860
  args: commandArgs.slice(1),
4861
+ url: cmd.url,
4862
+ headers: cmd.header,
4863
+ authSecret: cmd.authSecret,
4367
4864
  inheritEnv: cmd.inheritEnv === true,
4368
- label: cmd.label
4865
+ // commander turns --no-redact into redact:false; undefined → policy decides
4866
+ redact: cmd.redact === false ? false : void 0,
4867
+ label: cmd.label,
4868
+ projectPath: cmd.projectPath
4369
4869
  });
4370
4870
  process.exit(code);
4371
4871
  } catch (err) {
@@ -4377,6 +4877,59 @@ function registerMcpCommands(program2) {
4377
4877
  process.exit(1);
4378
4878
  }
4379
4879
  });
4880
+ mcp.command("approve <tool>").description(
4881
+ "Grant a time-limited approval for a wrapped tool listed in policy.wrap.approveTools (or revoke it)"
4882
+ ).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) => {
4883
+ const projectPath = cmd.projectPath ?? process.cwd();
4884
+ const service = wrapApprovalService(projectPath);
4885
+ if (cmd.revoke) {
4886
+ if (revokeApproval(tool, WRAP_APPROVAL_SCOPE, service)) {
4887
+ console.log(
4888
+ `${SYMBOLS.check} ${c.green("revoked")} airlock approval for ${c.bold(tool)}`
4889
+ );
4890
+ } else {
4891
+ console.error(c.red(`${SYMBOLS.cross} no airlock approval found for "${tool}"`));
4892
+ process.exit(1);
4893
+ }
4894
+ return;
4895
+ }
4896
+ const entry = grantApproval(tool, WRAP_APPROVAL_SCOPE, service, cmd.for, {
4897
+ reason: cmd.reason ?? "manual airlock approval"
4898
+ });
4899
+ console.log(
4900
+ `${SYMBOLS.check} ${c.green("approved")} wrapped tool ${c.bold(tool)} for ${cmd.for}s`
4901
+ );
4902
+ console.log(c.dim(` id=${entry.id} reason="${entry.reason}" expires=${entry.expiresAt}`));
4903
+ console.log(
4904
+ c.dim(
4905
+ " Applies to airlocks launched from this project directory (policy.wrap.approveTools)."
4906
+ )
4907
+ );
4908
+ });
4909
+ 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) => {
4910
+ const service = wrapApprovalService(cmd.projectPath ?? process.cwd());
4911
+ const entries = listApprovals().filter(
4912
+ (a) => a.scope === WRAP_APPROVAL_SCOPE && a.service === service
4913
+ );
4914
+ if (cmd.json) {
4915
+ console.log(JSON.stringify({ approvals: entries }, null, 2));
4916
+ return;
4917
+ }
4918
+ if (entries.length === 0) {
4919
+ console.log(
4920
+ c.dim(
4921
+ 'No airlock approvals. Grant one: qring mcp approve <tool> --for 3600 --reason "\u2026"'
4922
+ )
4923
+ );
4924
+ return;
4925
+ }
4926
+ for (const a of entries) {
4927
+ const state = a.tampered ? c.red("TAMPERED") : a.valid ? c.green("valid") : c.yellow("expired");
4928
+ console.log(
4929
+ ` ${SYMBOLS.check} ${c.bold(a.key)} ${state} ${c.dim(`expires ${a.expiresAt} \xB7 ${a.reason}`)}`
4930
+ );
4931
+ }
4932
+ });
4380
4933
  }
4381
4934
 
4382
4935
  // src/cli/commands/doctor.ts
@@ -4571,13 +5124,13 @@ function registerDoctorCommand(program2) {
4571
5124
  }
4572
5125
 
4573
5126
  // src/cli/commands/completion.ts
4574
- function collect(cmd) {
5127
+ function collect2(cmd) {
4575
5128
  return {
4576
5129
  name: cmd.name(),
4577
5130
  aliases: cmd.aliases(),
4578
5131
  description: cmd.description(),
4579
5132
  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))
5133
+ subcommands: cmd.commands.filter((s) => s.name() !== "help").map((s) => collect2(s))
4581
5134
  };
4582
5135
  }
4583
5136
  var sanitize = (s) => s.replace(/['"`$\\]/g, "").replace(/[:[\]]/g, " ").replace(/\s+/g, " ").trim();
@@ -4668,7 +5221,7 @@ function fishScript(cmds) {
4668
5221
  }
4669
5222
  function registerCompletionCommand(program2) {
4670
5223
  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));
5224
+ const cmds = program2.commands.filter((s) => s.name() !== "help" && s.name() !== "completion").map((s) => collect2(s));
4672
5225
  if (shell === "bash") process.stdout.write(bashScript(cmds));
4673
5226
  else if (shell === "zsh") process.stdout.write(zshScript(cmds));
4674
5227
  else if (shell === "fish") process.stdout.write(fishScript(cmds));