@i4ctime/q-ring 0.17.0 → 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/README.md +89 -7
- package/dist/{chunk-SQKTDYSO.js → chunk-5ZCQSBVS.js} +352 -18
- package/dist/chunk-5ZCQSBVS.js.map +1 -0
- package/dist/{chunk-KFILBHOY.js → chunk-VMDD5QTJ.js} +247 -18
- package/dist/chunk-VMDD5QTJ.js.map +1 -0
- package/dist/{dashboard-2MLSJZQR.js → dashboard-K7LYTWSL.js} +33 -6
- package/dist/dashboard-K7LYTWSL.js.map +1 -0
- package/dist/{dashboard-WVPR5BQO.js → dashboard-ZN3FPR73.js} +33 -6
- package/dist/dashboard-ZN3FPR73.js.map +1 -0
- package/dist/index.js +659 -115
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +68 -4
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-KFILBHOY.js.map +0 -1
- package/dist/chunk-SQKTDYSO.js.map +0 -1
- package/dist/dashboard-2MLSJZQR.js.map +0 -1
- package/dist/dashboard-WVPR5BQO.js.map +0 -1
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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${
|
|
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: () =>
|
|
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: () =>
|
|
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: () =>
|
|
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: () =>
|
|
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(
|
|
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
|
-
...
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4229
|
-
|
|
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
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
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
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
params: { ...progress, progressToken: hostToken }
|
|
4249
|
-
}).catch(() => {
|
|
4662
|
+
}
|
|
4663
|
+
function registerResourceHandlers() {
|
|
4664
|
+
downstream.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
|
|
4665
|
+
void proxy.sendResourceListChanged().catch(() => {
|
|
4250
4666
|
});
|
|
4251
|
-
}
|
|
4252
|
-
|
|
4253
|
-
|
|
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
|
-
}
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
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
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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}${
|
|
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 ${
|
|
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
|
|
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)").
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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));
|