@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/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 +669 -116
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +68 -4
- package/dist/mcp.js.map +1 -1
- package/package.json +6 -6
- 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,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
|
-
|
|
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${
|
|
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: () =>
|
|
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: () =>
|
|
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: () =>
|
|
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: () =>
|
|
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(
|
|
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
|
-
...
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4229
|
-
|
|
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
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
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
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
params: { ...progress, progressToken: hostToken }
|
|
4249
|
-
}).catch(() => {
|
|
4671
|
+
}
|
|
4672
|
+
function registerResourceHandlers() {
|
|
4673
|
+
downstream.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
|
|
4674
|
+
void proxy.sendResourceListChanged().catch(() => {
|
|
4250
4675
|
});
|
|
4251
|
-
}
|
|
4252
|
-
|
|
4253
|
-
|
|
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
|
-
}
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
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
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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}${
|
|
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 ${
|
|
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
|
|
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)").
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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));
|