@i4ctime/q-ring 0.14.1 → 0.16.0
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 +114 -2
- package/dist/{chunk-C2TFJ2EH.js → chunk-5LFKBZ3Q.js} +383 -125
- package/dist/chunk-5LFKBZ3Q.js.map +1 -0
- package/dist/{chunk-NNIEXAW5.js → chunk-NCM5GHNW.js} +380 -122
- package/dist/chunk-NCM5GHNW.js.map +1 -0
- package/dist/{dashboard-KAUEPLXO.js → dashboard-A3GJQCJX.js} +2 -2
- package/dist/{dashboard-PYYAED45.js → dashboard-R3FWTFFW.js} +2 -2
- package/dist/index.js +930 -19
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +110 -8
- package/dist/mcp.js.map +1 -1
- package/package.json +6 -4
- package/dist/chunk-C2TFJ2EH.js.map +0 -1
- package/dist/chunk-NNIEXAW5.js.map +0 -1
- /package/dist/{dashboard-KAUEPLXO.js.map → dashboard-A3GJQCJX.js.map} +0 -0
- /package/dist/{dashboard-PYYAED45.js.map → dashboard-R3FWTFFW.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -38,13 +38,14 @@ import {
|
|
|
38
38
|
removeHook,
|
|
39
39
|
revokeApproval,
|
|
40
40
|
serviceForScope,
|
|
41
|
+
setAuditAgentLabel,
|
|
41
42
|
setSecret,
|
|
42
43
|
tunnelCreate,
|
|
43
44
|
tunnelDestroy,
|
|
44
45
|
tunnelList,
|
|
45
46
|
tunnelRead,
|
|
46
47
|
verifyAuditChain
|
|
47
|
-
} from "./chunk-
|
|
48
|
+
} from "./chunk-5LFKBZ3Q.js";
|
|
48
49
|
|
|
49
50
|
// src/cli/commands.ts
|
|
50
51
|
import { Command, Help } from "commander";
|
|
@@ -764,6 +765,79 @@ var ProviderRegistry = class {
|
|
|
764
765
|
return [...this.providers.values()];
|
|
765
766
|
}
|
|
766
767
|
};
|
|
768
|
+
function livenessProvider(cfg) {
|
|
769
|
+
return {
|
|
770
|
+
name: cfg.name,
|
|
771
|
+
description: cfg.description,
|
|
772
|
+
prefixes: cfg.prefixes,
|
|
773
|
+
async validate(value) {
|
|
774
|
+
const start = Date.now();
|
|
775
|
+
try {
|
|
776
|
+
const { statusCode } = await makeRequest(cfg.url, {
|
|
777
|
+
"User-Agent": "q-ring-validator/1.0",
|
|
778
|
+
...cfg.headers(value)
|
|
779
|
+
});
|
|
780
|
+
const latencyMs = Date.now() - start;
|
|
781
|
+
if (statusCode === 200)
|
|
782
|
+
return { valid: true, status: "valid", message: "API key is valid", latencyMs, provider: cfg.name };
|
|
783
|
+
if (statusCode === 401 || statusCode === 403)
|
|
784
|
+
return { valid: false, status: "invalid", message: `Invalid or revoked API key (${statusCode})`, latencyMs, provider: cfg.name };
|
|
785
|
+
if (statusCode === 429)
|
|
786
|
+
return { valid: true, status: "error", message: "Rate limited \u2014 key may be valid", latencyMs, provider: cfg.name };
|
|
787
|
+
return { valid: false, status: "error", message: `Unexpected status ${statusCode}`, latencyMs, provider: cfg.name };
|
|
788
|
+
} catch (err) {
|
|
789
|
+
return { valid: false, status: "error", message: `${err instanceof Error ? err.message : "Network error"}`, latencyMs: Date.now() - start, provider: cfg.name };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
var anthropicProvider = livenessProvider({
|
|
795
|
+
name: "anthropic",
|
|
796
|
+
description: "Anthropic API key validation",
|
|
797
|
+
prefixes: ["sk-ant-"],
|
|
798
|
+
url: "https://api.anthropic.com/v1/models?limit=1",
|
|
799
|
+
headers: (value) => ({ "x-api-key": value, "anthropic-version": "2023-06-01" })
|
|
800
|
+
});
|
|
801
|
+
var openrouterProvider = livenessProvider({
|
|
802
|
+
name: "openrouter",
|
|
803
|
+
description: "OpenRouter API key validation",
|
|
804
|
+
prefixes: ["sk-or-"],
|
|
805
|
+
url: "https://openrouter.ai/api/v1/key",
|
|
806
|
+
headers: (value) => ({ Authorization: `Bearer ${value}` })
|
|
807
|
+
});
|
|
808
|
+
var googleAiProvider = livenessProvider({
|
|
809
|
+
name: "google-ai",
|
|
810
|
+
description: "Google AI (Gemini) API key validation",
|
|
811
|
+
prefixes: ["AIza"],
|
|
812
|
+
url: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
|
|
813
|
+
headers: (value) => ({ "x-goog-api-key": value })
|
|
814
|
+
});
|
|
815
|
+
var groqProvider = livenessProvider({
|
|
816
|
+
name: "groq",
|
|
817
|
+
description: "Groq API key validation",
|
|
818
|
+
prefixes: ["gsk_"],
|
|
819
|
+
url: "https://api.groq.com/openai/v1/models",
|
|
820
|
+
headers: (value) => ({ Authorization: `Bearer ${value}` })
|
|
821
|
+
});
|
|
822
|
+
var huggingfaceProvider = livenessProvider({
|
|
823
|
+
name: "huggingface",
|
|
824
|
+
description: "Hugging Face token validation",
|
|
825
|
+
prefixes: ["hf_"],
|
|
826
|
+
url: "https://huggingface.co/api/whoami-v2",
|
|
827
|
+
headers: (value) => ({ Authorization: `Bearer ${value}` })
|
|
828
|
+
});
|
|
829
|
+
var elevenlabsProvider = livenessProvider({
|
|
830
|
+
name: "elevenlabs",
|
|
831
|
+
description: "ElevenLabs API key validation (explicit-only \u2014 set provider on the secret)",
|
|
832
|
+
url: "https://api.elevenlabs.io/v1/user",
|
|
833
|
+
headers: (value) => ({ "xi-api-key": value })
|
|
834
|
+
});
|
|
835
|
+
var vercelProvider = livenessProvider({
|
|
836
|
+
name: "vercel",
|
|
837
|
+
description: "Vercel token validation (explicit-only \u2014 set provider on the secret)",
|
|
838
|
+
url: "https://api.vercel.com/v2/user",
|
|
839
|
+
headers: (value) => ({ Authorization: `Bearer ${value}` })
|
|
840
|
+
});
|
|
767
841
|
var openaiProvider = {
|
|
768
842
|
name: "openai",
|
|
769
843
|
description: "OpenAI API key validation",
|
|
@@ -890,7 +964,14 @@ var httpProvider = {
|
|
|
890
964
|
}
|
|
891
965
|
};
|
|
892
966
|
var registry2 = new ProviderRegistry();
|
|
967
|
+
registry2.register(anthropicProvider);
|
|
968
|
+
registry2.register(openrouterProvider);
|
|
893
969
|
registry2.register(openaiProvider);
|
|
970
|
+
registry2.register(googleAiProvider);
|
|
971
|
+
registry2.register(groqProvider);
|
|
972
|
+
registry2.register(huggingfaceProvider);
|
|
973
|
+
registry2.register(elevenlabsProvider);
|
|
974
|
+
registry2.register(vercelProvider);
|
|
894
975
|
registry2.register(stripeProvider);
|
|
895
976
|
registry2.register(githubProvider);
|
|
896
977
|
registry2.register(awsProvider);
|
|
@@ -1984,10 +2065,9 @@ var RedactionTransform = class extends Transform {
|
|
|
1984
2065
|
callback();
|
|
1985
2066
|
}
|
|
1986
2067
|
};
|
|
1987
|
-
|
|
1988
|
-
const
|
|
1989
|
-
const
|
|
1990
|
-
const policyDecision = checkExecPolicy(fullCommand, opts.projectPath);
|
|
2068
|
+
function enforceExecPolicy(profile, command, args, projectPath) {
|
|
2069
|
+
const fullCommand = [command, ...args].join(" ");
|
|
2070
|
+
const policyDecision = checkExecPolicy(fullCommand, projectPath);
|
|
1991
2071
|
if (!policyDecision.allowed) {
|
|
1992
2072
|
throw new Error(`Policy Denied: ${policyDecision.reason}`);
|
|
1993
2073
|
}
|
|
@@ -2003,9 +2083,13 @@ async function execCommand(opts) {
|
|
|
2003
2083
|
if (profile.allowCommands) {
|
|
2004
2084
|
const allowed = profile.allowCommands.some((a) => fullCommand.startsWith(a));
|
|
2005
2085
|
if (!allowed) {
|
|
2006
|
-
throw new Error(`Exec profile "${profile.name}" does not allow command "${
|
|
2086
|
+
throw new Error(`Exec profile "${profile.name}" does not allow command "${command}"`);
|
|
2007
2087
|
}
|
|
2008
2088
|
}
|
|
2089
|
+
}
|
|
2090
|
+
async function execCommand(opts) {
|
|
2091
|
+
const profile = getProfile(opts.profile);
|
|
2092
|
+
enforceExecPolicy(profile, opts.command, opts.args, opts.projectPath);
|
|
2009
2093
|
const envMap = {};
|
|
2010
2094
|
for (const [k, v] of Object.entries(process.env)) {
|
|
2011
2095
|
if (v !== void 0) envMap[k] = v;
|
|
@@ -2052,6 +2136,18 @@ async function execCommand(opts) {
|
|
|
2052
2136
|
}
|
|
2053
2137
|
}
|
|
2054
2138
|
}
|
|
2139
|
+
return spawnRedacted({
|
|
2140
|
+
profile,
|
|
2141
|
+
command: opts.command,
|
|
2142
|
+
args: opts.args,
|
|
2143
|
+
envMap,
|
|
2144
|
+
secretsToRedact: [...secretsToRedact],
|
|
2145
|
+
captureOutput: opts.captureOutput,
|
|
2146
|
+
projectPath: opts.projectPath
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
function spawnRedacted(opts) {
|
|
2150
|
+
const { profile, secretsToRedact, envMap } = opts;
|
|
2055
2151
|
const maxRuntime = profile.maxRuntimeSeconds ?? getExecMaxRuntime(opts.projectPath);
|
|
2056
2152
|
return new Promise((resolve, reject) => {
|
|
2057
2153
|
const networkTools = /* @__PURE__ */ new Set([
|
|
@@ -2492,7 +2588,7 @@ function registerToolingCommands(program2) {
|
|
|
2492
2588
|
}
|
|
2493
2589
|
});
|
|
2494
2590
|
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) => {
|
|
2495
|
-
const { startDashboardServer } = await import("./dashboard-
|
|
2591
|
+
const { startDashboardServer } = await import("./dashboard-A3GJQCJX.js");
|
|
2496
2592
|
const { exec } = await import("child_process");
|
|
2497
2593
|
const { platform } = await import("os");
|
|
2498
2594
|
const port = Number(cmd.port);
|
|
@@ -3407,12 +3503,819 @@ ${SYMBOLS.lock} Approval Tokens
|
|
|
3407
3503
|
});
|
|
3408
3504
|
}
|
|
3409
3505
|
|
|
3410
|
-
// src/
|
|
3411
|
-
import { existsSync as existsSync4, readFileSync as readFileSync6
|
|
3412
|
-
import { join as join3,
|
|
3506
|
+
// src/core/run.ts
|
|
3507
|
+
import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
|
|
3508
|
+
import { join as join3, resolve as resolvePath } from "path";
|
|
3509
|
+
|
|
3510
|
+
// src/core/refs.ts
|
|
3511
|
+
var REF_PREFIX = "qring://";
|
|
3512
|
+
var KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3513
|
+
var REF_PATTERN = /^qring:\/\/(global|project|)\/([^/?#]+)(?:\?([^#]*))?$/;
|
|
3514
|
+
function isRef(value) {
|
|
3515
|
+
return value.startsWith(REF_PREFIX);
|
|
3516
|
+
}
|
|
3517
|
+
function parseRef(raw) {
|
|
3518
|
+
if (!isRef(raw)) {
|
|
3519
|
+
throw new Error(`Not a qring:// reference: "${raw}"`);
|
|
3520
|
+
}
|
|
3521
|
+
const match = REF_PATTERN.exec(raw);
|
|
3522
|
+
if (!match) {
|
|
3523
|
+
const hostOnly = /^qring:\/\/([^/?#]+)\/?$/.exec(raw);
|
|
3524
|
+
if (hostOnly && KEY_PATTERN.test(hostOnly[1]) && !["global", "project"].includes(hostOnly[1])) {
|
|
3525
|
+
throw new Error(
|
|
3526
|
+
`Invalid ref "${raw}": the key belongs in the path, not the host (URL hosts are lowercased by parsers). Use "qring:///${hostOnly[1]}" (auto scope) or "qring://project/${hostOnly[1]}".`
|
|
3527
|
+
);
|
|
3528
|
+
}
|
|
3529
|
+
throw new Error(
|
|
3530
|
+
`Invalid ref "${raw}": expected qring://<global|project|>/KEY[?env=<env>]`
|
|
3531
|
+
);
|
|
3532
|
+
}
|
|
3533
|
+
const [, scopeRaw, key, query] = match;
|
|
3534
|
+
if (!KEY_PATTERN.test(key)) {
|
|
3535
|
+
throw new Error(
|
|
3536
|
+
`Invalid ref "${raw}": "${key}" is not a valid secret key (expected [A-Za-z_][A-Za-z0-9_]*)`
|
|
3537
|
+
);
|
|
3538
|
+
}
|
|
3539
|
+
let env;
|
|
3540
|
+
if (query) {
|
|
3541
|
+
for (const part of query.split("&")) {
|
|
3542
|
+
if (!part) continue;
|
|
3543
|
+
const eq = part.indexOf("=");
|
|
3544
|
+
const name = eq === -1 ? part : part.slice(0, eq);
|
|
3545
|
+
const value = eq === -1 ? "" : decodeURIComponent(part.slice(eq + 1));
|
|
3546
|
+
if (name === "env") {
|
|
3547
|
+
env = value;
|
|
3548
|
+
} else {
|
|
3549
|
+
throw new Error(`Invalid ref "${raw}": unknown query parameter "${name}"`);
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
return {
|
|
3554
|
+
scope: scopeRaw === "" ? void 0 : scopeRaw,
|
|
3555
|
+
key,
|
|
3556
|
+
env,
|
|
3557
|
+
raw
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
function resolveRef(ref, opts = {}) {
|
|
3561
|
+
const scopes = ref.scope ? [ref.scope] : ["project", "global"];
|
|
3562
|
+
for (const scope of scopes) {
|
|
3563
|
+
const value = getSecret(ref.key, {
|
|
3564
|
+
...opts,
|
|
3565
|
+
scope,
|
|
3566
|
+
env: ref.env ?? opts.env
|
|
3567
|
+
});
|
|
3568
|
+
if (value !== null) return value;
|
|
3569
|
+
}
|
|
3570
|
+
return null;
|
|
3571
|
+
}
|
|
3572
|
+
function resolveRefsInMap(map, opts = {}) {
|
|
3573
|
+
const resolved = {};
|
|
3574
|
+
const secretValues = [];
|
|
3575
|
+
const missing = [];
|
|
3576
|
+
for (const [name, value] of Object.entries(map)) {
|
|
3577
|
+
if (!isRef(value)) {
|
|
3578
|
+
resolved[name] = value;
|
|
3579
|
+
continue;
|
|
3580
|
+
}
|
|
3581
|
+
const ref = parseRef(value);
|
|
3582
|
+
const secret = resolveRef(ref, opts);
|
|
3583
|
+
if (secret === null) {
|
|
3584
|
+
missing.push({ name, ref });
|
|
3585
|
+
continue;
|
|
3586
|
+
}
|
|
3587
|
+
resolved[name] = secret;
|
|
3588
|
+
secretValues.push(secret);
|
|
3589
|
+
}
|
|
3590
|
+
return { resolved, secretValues, missing };
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
// src/core/run.ts
|
|
3594
|
+
function buildRunPlan(opts) {
|
|
3595
|
+
const projectPath = opts.projectPath ?? process.cwd();
|
|
3596
|
+
const keyringOpts = {
|
|
3597
|
+
projectPath,
|
|
3598
|
+
env: opts.env,
|
|
3599
|
+
source: opts.source ?? "cli",
|
|
3600
|
+
silent: opts.silent
|
|
3601
|
+
};
|
|
3602
|
+
const envMap = {};
|
|
3603
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
3604
|
+
if (v !== void 0) envMap[k] = v;
|
|
3605
|
+
}
|
|
3606
|
+
const injected = [];
|
|
3607
|
+
const missingRequired = [];
|
|
3608
|
+
const missingOptional = [];
|
|
3609
|
+
const secretsToRedact = /* @__PURE__ */ new Set();
|
|
3610
|
+
if (opts.useManifest !== false) {
|
|
3611
|
+
const config = readProjectConfig(projectPath);
|
|
3612
|
+
for (const [key, entry] of Object.entries(config?.secrets ?? {})) {
|
|
3613
|
+
const value = resolveRef(
|
|
3614
|
+
{ key, raw: `manifest:${key}` },
|
|
3615
|
+
keyringOpts
|
|
3616
|
+
);
|
|
3617
|
+
if (value === null) {
|
|
3618
|
+
if (entry.required !== false) missingRequired.push(key);
|
|
3619
|
+
else if (opts.strict) missingRequired.push(key);
|
|
3620
|
+
else missingOptional.push(key);
|
|
3621
|
+
continue;
|
|
3622
|
+
}
|
|
3623
|
+
envMap[key] = value;
|
|
3624
|
+
injected.push({ name: key, source: "manifest" });
|
|
3625
|
+
if (value.length > 5) secretsToRedact.add(value);
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
const envFiles = opts.envFiles ?? (existsSync4(join3(projectPath, ".env")) ? [join3(projectPath, ".env")] : []);
|
|
3629
|
+
for (const file of envFiles) {
|
|
3630
|
+
const path = resolvePath(projectPath, file);
|
|
3631
|
+
if (!existsSync4(path)) {
|
|
3632
|
+
throw new Error(`Env file not found: ${path}`);
|
|
3633
|
+
}
|
|
3634
|
+
const parsed = Object.fromEntries(parseDotenv(readFileSync6(path, "utf8")));
|
|
3635
|
+
const { resolved, secretValues, missing } = resolveRefsInMap(parsed, keyringOpts);
|
|
3636
|
+
for (const [name, value] of Object.entries(resolved)) {
|
|
3637
|
+
envMap[name] = value;
|
|
3638
|
+
injected.push({
|
|
3639
|
+
name,
|
|
3640
|
+
source: isRef(parsed[name]) ? "env-file-ref" : "env-file"
|
|
3641
|
+
});
|
|
3642
|
+
}
|
|
3643
|
+
for (const value of secretValues) {
|
|
3644
|
+
if (value.length > 5) secretsToRedact.add(value);
|
|
3645
|
+
}
|
|
3646
|
+
for (const m of missing) missingRequired.push(m.name);
|
|
3647
|
+
}
|
|
3648
|
+
return {
|
|
3649
|
+
envFiles: envFiles.map((f) => resolvePath(projectPath, f)),
|
|
3650
|
+
injected,
|
|
3651
|
+
missingRequired,
|
|
3652
|
+
missingOptional,
|
|
3653
|
+
envMap,
|
|
3654
|
+
secretsToRedact: [...secretsToRedact]
|
|
3655
|
+
};
|
|
3656
|
+
}
|
|
3657
|
+
async function runCommand(opts) {
|
|
3658
|
+
const profile = getProfile(opts.profile);
|
|
3659
|
+
enforceExecPolicy(profile, opts.command, opts.args, opts.projectPath);
|
|
3660
|
+
const plan = buildRunPlan(opts);
|
|
3661
|
+
if (plan.missingRequired.length > 0) {
|
|
3662
|
+
throw new Error(
|
|
3663
|
+
`Missing required secrets: ${plan.missingRequired.join(", ")}. Store them with \`qring set <KEY>\` or drop them from the manifest/.env refs.`
|
|
3664
|
+
);
|
|
3665
|
+
}
|
|
3666
|
+
if (profile.stripEnvVars) {
|
|
3667
|
+
for (const key of profile.stripEnvVars) {
|
|
3668
|
+
delete plan.envMap[key];
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
const result = await spawnRedacted({
|
|
3672
|
+
profile,
|
|
3673
|
+
command: opts.command,
|
|
3674
|
+
args: opts.args,
|
|
3675
|
+
envMap: plan.envMap,
|
|
3676
|
+
secretsToRedact: plan.secretsToRedact,
|
|
3677
|
+
captureOutput: opts.captureOutput,
|
|
3678
|
+
projectPath: opts.projectPath
|
|
3679
|
+
});
|
|
3680
|
+
return { ...result, plan };
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
// src/core/setup.ts
|
|
3684
|
+
import { existsSync as existsSync5, mkdirSync, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
3413
3685
|
import { homedir } from "os";
|
|
3686
|
+
import { dirname, join as join4 } from "path";
|
|
3687
|
+
var EDITORS = ["cursor", "kiro", "claude"];
|
|
3688
|
+
var READ_ONLY_TOOLS = [
|
|
3689
|
+
"list_secrets",
|
|
3690
|
+
"has_secret",
|
|
3691
|
+
"inspect_secret",
|
|
3692
|
+
"detect_environment",
|
|
3693
|
+
"get_project_context",
|
|
3694
|
+
"list_providers",
|
|
3695
|
+
"list_hooks",
|
|
3696
|
+
"get_policy_summary",
|
|
3697
|
+
"audit_log",
|
|
3698
|
+
"verify_audit_chain",
|
|
3699
|
+
"health_check",
|
|
3700
|
+
"analyze_secrets"
|
|
3701
|
+
];
|
|
3702
|
+
var SERVER_NAME = "q-ring";
|
|
3703
|
+
function serverEntry(editor) {
|
|
3704
|
+
switch (editor) {
|
|
3705
|
+
case "cursor":
|
|
3706
|
+
return { command: "qring-mcp", args: [], env: {} };
|
|
3707
|
+
case "kiro":
|
|
3708
|
+
return {
|
|
3709
|
+
command: "qring-mcp",
|
|
3710
|
+
args: [],
|
|
3711
|
+
env: {},
|
|
3712
|
+
disabled: false,
|
|
3713
|
+
autoApprove: READ_ONLY_TOOLS
|
|
3714
|
+
};
|
|
3715
|
+
case "claude":
|
|
3716
|
+
return { type: "stdio", command: "qring-mcp", args: [], env: {} };
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
function configPathFor(editor, global, projectPath) {
|
|
3720
|
+
const home = homedir();
|
|
3721
|
+
switch (editor) {
|
|
3722
|
+
case "cursor":
|
|
3723
|
+
return global ? join4(home, ".cursor", "mcp.json") : join4(projectPath, ".cursor", "mcp.json");
|
|
3724
|
+
case "kiro":
|
|
3725
|
+
return global ? join4(home, ".kiro", "settings", "mcp.json") : join4(projectPath, ".kiro", "settings", "mcp.json");
|
|
3726
|
+
case "claude":
|
|
3727
|
+
if (global) {
|
|
3728
|
+
throw new Error(
|
|
3729
|
+
"Claude Code user-scoped MCP servers are registered via the claude CLI, not a config file.\nRun: claude mcp add --scope user q-ring qring-mcp\nOr install the plugin: /plugin marketplace add I4cTime/q-ring && /plugin install qring@q-ring"
|
|
3730
|
+
);
|
|
3731
|
+
}
|
|
3732
|
+
return join4(projectPath, ".mcp.json");
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
function setupEditor(opts) {
|
|
3736
|
+
const projectPath = opts.projectPath ?? process.cwd();
|
|
3737
|
+
const configPath = configPathFor(opts.editor, opts.global ?? false, projectPath);
|
|
3738
|
+
const warnings = [];
|
|
3739
|
+
let config = {};
|
|
3740
|
+
if (existsSync5(configPath)) {
|
|
3741
|
+
const raw = readFileSync7(configPath, "utf8");
|
|
3742
|
+
try {
|
|
3743
|
+
config = JSON.parse(raw);
|
|
3744
|
+
} catch {
|
|
3745
|
+
throw new Error(
|
|
3746
|
+
`${configPath} exists but is not valid JSON \u2014 fix or remove it, then re-run.`
|
|
3747
|
+
);
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
const servers = config.mcpServers ?? {};
|
|
3751
|
+
const desired = serverEntry(opts.editor);
|
|
3752
|
+
const existing = servers[SERVER_NAME];
|
|
3753
|
+
let action;
|
|
3754
|
+
if (existing === void 0) {
|
|
3755
|
+
action = existsSync5(configPath) ? "updated" : "created";
|
|
3756
|
+
servers[SERVER_NAME] = desired;
|
|
3757
|
+
} else if (JSON.stringify(existing) === JSON.stringify(desired)) {
|
|
3758
|
+
action = "unchanged";
|
|
3759
|
+
} else if (opts.force) {
|
|
3760
|
+
action = "updated";
|
|
3761
|
+
servers[SERVER_NAME] = desired;
|
|
3762
|
+
} else {
|
|
3763
|
+
action = "unchanged";
|
|
3764
|
+
warnings.push(
|
|
3765
|
+
`A different "${SERVER_NAME}" entry already exists in ${configPath} \u2014 left as-is (use --force to replace it).`
|
|
3766
|
+
);
|
|
3767
|
+
}
|
|
3768
|
+
config.mcpServers = servers;
|
|
3769
|
+
if (action !== "unchanged" && !opts.dryRun) {
|
|
3770
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
3771
|
+
writeFileSync5(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
3772
|
+
}
|
|
3773
|
+
return {
|
|
3774
|
+
editor: opts.editor,
|
|
3775
|
+
configPath,
|
|
3776
|
+
serverName: SERVER_NAME,
|
|
3777
|
+
action,
|
|
3778
|
+
dryRun: opts.dryRun ?? false,
|
|
3779
|
+
warnings
|
|
3780
|
+
};
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
// src/core/push.ts
|
|
3784
|
+
import { spawnSync } from "child_process";
|
|
3785
|
+
var PUSH_TARGETS = ["github", "vercel", "cloudflare"];
|
|
3786
|
+
var TARGETS = {
|
|
3787
|
+
github: {
|
|
3788
|
+
binary: "gh",
|
|
3789
|
+
args: (key, opts) => [
|
|
3790
|
+
["secret", "set", key, ...opts.repo ? ["--repo", opts.repo] : []]
|
|
3791
|
+
],
|
|
3792
|
+
installHint: "install the GitHub CLI: https://cli.github.com (then `gh auth login`)"
|
|
3793
|
+
},
|
|
3794
|
+
vercel: {
|
|
3795
|
+
binary: "vercel",
|
|
3796
|
+
// One invocation per environment — `vercel env add` takes a single target.
|
|
3797
|
+
args: (key, opts) => (opts.vercelEnvs ?? ["production"]).map((env) => [
|
|
3798
|
+
"env",
|
|
3799
|
+
"add",
|
|
3800
|
+
key,
|
|
3801
|
+
env,
|
|
3802
|
+
"--force"
|
|
3803
|
+
]),
|
|
3804
|
+
installHint: "install the Vercel CLI: npm i -g vercel (then `vercel link` in the project)"
|
|
3805
|
+
},
|
|
3806
|
+
cloudflare: {
|
|
3807
|
+
binary: "wrangler",
|
|
3808
|
+
args: (key) => [["secret", "put", key]],
|
|
3809
|
+
installHint: "install Wrangler: npm i -g wrangler (then `wrangler login`)"
|
|
3810
|
+
}
|
|
3811
|
+
};
|
|
3812
|
+
function binaryAvailable(binary) {
|
|
3813
|
+
const probe = spawnSync(binary, ["--version"], { stdio: "ignore", shell: false });
|
|
3814
|
+
return !probe.error;
|
|
3815
|
+
}
|
|
3816
|
+
function resolvePushKeys(opts) {
|
|
3817
|
+
if (opts.keys?.length) return opts.keys;
|
|
3818
|
+
const config = readProjectConfig(opts.projectPath);
|
|
3819
|
+
const manifestKeys = Object.keys(config?.secrets ?? {});
|
|
3820
|
+
if (manifestKeys.length === 0) {
|
|
3821
|
+
throw new Error(
|
|
3822
|
+
"Nothing to push: no --keys given and no secrets manifest in .q-ring.json. Declare the project's secrets there or pass --keys KEY1,KEY2."
|
|
3823
|
+
);
|
|
3824
|
+
}
|
|
3825
|
+
return manifestKeys;
|
|
3826
|
+
}
|
|
3827
|
+
function pushSecrets(opts) {
|
|
3828
|
+
const target = TARGETS[opts.target];
|
|
3829
|
+
const keys = resolvePushKeys(opts);
|
|
3830
|
+
if (!opts.dryRun && !binaryAvailable(target.binary)) {
|
|
3831
|
+
throw new Error(`"${target.binary}" CLI not found \u2014 ${target.installHint}`);
|
|
3832
|
+
}
|
|
3833
|
+
const result = { target: opts.target, pushed: [], failed: [], missing: [] };
|
|
3834
|
+
for (const key of keys) {
|
|
3835
|
+
const value = resolveRef(
|
|
3836
|
+
{ key, raw: `push:${key}` },
|
|
3837
|
+
{
|
|
3838
|
+
projectPath: opts.projectPath,
|
|
3839
|
+
env: opts.env,
|
|
3840
|
+
source: opts.source ?? "cli",
|
|
3841
|
+
silent: opts.silent
|
|
3842
|
+
}
|
|
3843
|
+
);
|
|
3844
|
+
if (value === null) {
|
|
3845
|
+
result.missing.push(key);
|
|
3846
|
+
continue;
|
|
3847
|
+
}
|
|
3848
|
+
if (opts.dryRun) {
|
|
3849
|
+
result.pushed.push(key);
|
|
3850
|
+
continue;
|
|
3851
|
+
}
|
|
3852
|
+
let failedInvocation = null;
|
|
3853
|
+
for (const args of target.args(key, opts)) {
|
|
3854
|
+
const child = spawnSync(target.binary, args, {
|
|
3855
|
+
input: value,
|
|
3856
|
+
cwd: opts.projectPath,
|
|
3857
|
+
encoding: "utf8",
|
|
3858
|
+
shell: false
|
|
3859
|
+
});
|
|
3860
|
+
if (child.status !== 0) {
|
|
3861
|
+
failedInvocation = child.stderr?.trim() || child.error?.message || `exit ${child.status}`;
|
|
3862
|
+
break;
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
if (failedInvocation) {
|
|
3866
|
+
result.failed.push({ key, error: failedInvocation });
|
|
3867
|
+
continue;
|
|
3868
|
+
}
|
|
3869
|
+
result.pushed.push(key);
|
|
3870
|
+
if (!opts.silent) {
|
|
3871
|
+
logAudit({
|
|
3872
|
+
action: "push",
|
|
3873
|
+
key,
|
|
3874
|
+
env: opts.env,
|
|
3875
|
+
source: opts.source ?? "cli",
|
|
3876
|
+
detail: `pushed to ${opts.target}${opts.repo ? ` (${opts.repo})` : ""}`
|
|
3877
|
+
});
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
return result;
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
// src/cli/commands/bridges.ts
|
|
3884
|
+
function registerBridgeCommands(program2) {
|
|
3885
|
+
program2.command("run <command...>").description(
|
|
3886
|
+
"Run a command with only declared secrets injected \u2014 .q-ring.json manifest keys plus qring:// refs from .env (output auto-redacted)"
|
|
3887
|
+
).option("--project-path <path>", "Explicit project path").option("-e, --env <env>", "Environment context").option("--env-file <files...>", "Env file(s) to load (default: ./.env when present)").option("--no-manifest", "Skip .q-ring.json manifest keys").option("--profile <name>", "Exec profile: unrestricted, restricted, ci").option("--strict", "Missing optional manifest keys are fatal too").option("--dry-run", "Show what would be injected without running").action(async (commandArgs, cmd) => {
|
|
3888
|
+
const opts = {
|
|
3889
|
+
command: commandArgs[0],
|
|
3890
|
+
args: commandArgs.slice(1),
|
|
3891
|
+
projectPath: cmd.projectPath ?? process.cwd(),
|
|
3892
|
+
env: cmd.env,
|
|
3893
|
+
envFiles: cmd.envFile,
|
|
3894
|
+
useManifest: cmd.manifest !== false,
|
|
3895
|
+
profile: cmd.profile,
|
|
3896
|
+
strict: cmd.strict === true,
|
|
3897
|
+
source: "cli"
|
|
3898
|
+
};
|
|
3899
|
+
try {
|
|
3900
|
+
if (cmd.dryRun) {
|
|
3901
|
+
const plan2 = buildRunPlan({ ...opts, silent: true });
|
|
3902
|
+
console.log(`
|
|
3903
|
+
${SYMBOLS.zap} ${c.bold("qring run")} ${c.dim("(dry run)")}`);
|
|
3904
|
+
if (plan2.envFiles.length > 0) {
|
|
3905
|
+
console.log(` Env files: ${plan2.envFiles.map((f) => c.cyan(f)).join(", ")}`);
|
|
3906
|
+
}
|
|
3907
|
+
for (const v of plan2.injected) {
|
|
3908
|
+
console.log(` ${c.green(SYMBOLS.check)} ${v.name} ${c.dim(`(${v.source})`)}`);
|
|
3909
|
+
}
|
|
3910
|
+
for (const name of plan2.missingOptional) {
|
|
3911
|
+
console.log(` ${c.yellow("?")} ${name} ${c.dim("(optional, missing)")}`);
|
|
3912
|
+
}
|
|
3913
|
+
for (const name of plan2.missingRequired) {
|
|
3914
|
+
console.log(` ${c.red(SYMBOLS.cross)} ${name} ${c.dim("(required, MISSING)")}`);
|
|
3915
|
+
}
|
|
3916
|
+
process.exit(plan2.missingRequired.length > 0 ? 1 : 0);
|
|
3917
|
+
}
|
|
3918
|
+
const { code, plan } = await runCommand(opts);
|
|
3919
|
+
if (plan.missingOptional.length > 0) {
|
|
3920
|
+
console.error(
|
|
3921
|
+
c.yellow(
|
|
3922
|
+
`${SYMBOLS.warning} Optional secrets not found: ${plan.missingOptional.join(", ")}`
|
|
3923
|
+
)
|
|
3924
|
+
);
|
|
3925
|
+
}
|
|
3926
|
+
process.exit(code);
|
|
3927
|
+
} catch (err) {
|
|
3928
|
+
console.error(
|
|
3929
|
+
c.red(
|
|
3930
|
+
`${SYMBOLS.cross} Run failed: ${err instanceof Error ? err.message : String(err)}`
|
|
3931
|
+
)
|
|
3932
|
+
);
|
|
3933
|
+
process.exit(1);
|
|
3934
|
+
}
|
|
3935
|
+
});
|
|
3936
|
+
program2.command("push <target>").description(
|
|
3937
|
+
`Push manifest secrets to a deployment platform via its own CLI (${PUSH_TARGETS.join(", ")}) \u2014 values travel over stdin, never argv`
|
|
3938
|
+
).option("-k, --keys <keys>", "Comma-separated keys (default: .q-ring.json manifest)").option("--project-path <path>", "Explicit project path").option("-e, --env <env>", "Environment context for superposition collapse").option("--repo <owner/name>", "GitHub repository (github target)").option(
|
|
3939
|
+
"--vercel-env <envs>",
|
|
3940
|
+
"Comma-separated Vercel environments (default: production)"
|
|
3941
|
+
).option("--dry-run", "Show what would be pushed without pushing").option("--json", "Output as JSON").action((targetArg, cmd) => {
|
|
3942
|
+
if (!PUSH_TARGETS.includes(targetArg)) {
|
|
3943
|
+
console.error(
|
|
3944
|
+
c.red(`${SYMBOLS.cross} Unknown target "${targetArg}" \u2014 expected one of: ${PUSH_TARGETS.join(", ")}`)
|
|
3945
|
+
);
|
|
3946
|
+
process.exit(1);
|
|
3947
|
+
}
|
|
3948
|
+
try {
|
|
3949
|
+
const result = pushSecrets({
|
|
3950
|
+
target: targetArg,
|
|
3951
|
+
keys: cmd.keys?.split(",").map((k) => k.trim()),
|
|
3952
|
+
projectPath: cmd.projectPath ?? process.cwd(),
|
|
3953
|
+
env: cmd.env,
|
|
3954
|
+
repo: cmd.repo,
|
|
3955
|
+
vercelEnvs: cmd.vercelEnv?.split(",").map((e) => e.trim()),
|
|
3956
|
+
dryRun: cmd.dryRun === true,
|
|
3957
|
+
source: "cli"
|
|
3958
|
+
});
|
|
3959
|
+
if (wantsJsonOutput(program2, cmd)) {
|
|
3960
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3961
|
+
process.exit(result.failed.length > 0 ? 1 : 0);
|
|
3962
|
+
}
|
|
3963
|
+
const prefix = cmd.dryRun ? c.dim("[dry-run] ") : "";
|
|
3964
|
+
console.log(`
|
|
3965
|
+
${SYMBOLS.link} ${prefix}${c.bold(`qring push ${targetArg}`)}`);
|
|
3966
|
+
for (const key of result.pushed) {
|
|
3967
|
+
console.log(` ${c.green(SYMBOLS.check)} ${key}`);
|
|
3968
|
+
}
|
|
3969
|
+
for (const key of result.missing) {
|
|
3970
|
+
console.log(` ${c.yellow("?")} ${key} ${c.dim("(not in keyring \u2014 skipped)")}`);
|
|
3971
|
+
}
|
|
3972
|
+
for (const { key, error } of result.failed) {
|
|
3973
|
+
console.log(` ${c.red(SYMBOLS.cross)} ${key} ${c.dim(`\u2014 ${error}`)}`);
|
|
3974
|
+
}
|
|
3975
|
+
console.log(
|
|
3976
|
+
c.dim(
|
|
3977
|
+
` ${result.pushed.length} pushed${cmd.dryRun ? " (dry run)" : ""}, ${result.missing.length} missing, ${result.failed.length} failed`
|
|
3978
|
+
)
|
|
3979
|
+
);
|
|
3980
|
+
process.exit(result.failed.length > 0 ? 1 : 0);
|
|
3981
|
+
} catch (err) {
|
|
3982
|
+
console.error(
|
|
3983
|
+
c.red(
|
|
3984
|
+
`${SYMBOLS.cross} Push failed: ${err instanceof Error ? err.message : String(err)}`
|
|
3985
|
+
)
|
|
3986
|
+
);
|
|
3987
|
+
process.exit(1);
|
|
3988
|
+
}
|
|
3989
|
+
});
|
|
3990
|
+
program2.command("setup <editor>").description(
|
|
3991
|
+
`Wire the q-ring MCP server into an editor's MCP config (${EDITORS.join(", ")})`
|
|
3992
|
+
).option("-g, --global", "Write the per-user config instead of the project one").option("--project-path <path>", "Explicit project path").option("--force", "Replace an existing q-ring entry that differs").option("--dry-run", "Show what would change without writing").option("--json", "Output as JSON").action((editor, cmd) => {
|
|
3993
|
+
if (!EDITORS.includes(editor)) {
|
|
3994
|
+
console.error(
|
|
3995
|
+
c.red(`${SYMBOLS.cross} Unknown editor "${editor}" \u2014 expected one of: ${EDITORS.join(", ")}`)
|
|
3996
|
+
);
|
|
3997
|
+
process.exit(1);
|
|
3998
|
+
}
|
|
3999
|
+
try {
|
|
4000
|
+
const result = setupEditor({
|
|
4001
|
+
editor,
|
|
4002
|
+
global: cmd.global === true,
|
|
4003
|
+
projectPath: cmd.projectPath ?? process.cwd(),
|
|
4004
|
+
force: cmd.force === true,
|
|
4005
|
+
dryRun: cmd.dryRun === true
|
|
4006
|
+
});
|
|
4007
|
+
if (wantsJsonOutput(program2, cmd)) {
|
|
4008
|
+
console.log(JSON.stringify(result, null, 2));
|
|
4009
|
+
return;
|
|
4010
|
+
}
|
|
4011
|
+
const verb = result.action === "created" ? "Created" : result.action === "updated" ? "Updated" : "Already configured";
|
|
4012
|
+
const prefix = result.dryRun ? c.dim("[dry-run] ") : "";
|
|
4013
|
+
console.log(
|
|
4014
|
+
`
|
|
4015
|
+
${SYMBOLS.check} ${prefix}${c.bold(verb)}: ${c.cyan(result.configPath)} ${c.dim(`(server "${result.serverName}")`)}`
|
|
4016
|
+
);
|
|
4017
|
+
for (const warning of result.warnings) {
|
|
4018
|
+
console.log(` ${c.yellow("!")} ${warning}`);
|
|
4019
|
+
}
|
|
4020
|
+
if (result.action !== "unchanged") {
|
|
4021
|
+
console.log(c.dim(` Restart ${editor} to pick up the MCP server. Verify with: qring doctor`));
|
|
4022
|
+
}
|
|
4023
|
+
} catch (err) {
|
|
4024
|
+
console.error(
|
|
4025
|
+
c.red(
|
|
4026
|
+
`${SYMBOLS.cross} Setup failed: ${err instanceof Error ? err.message : String(err)}`
|
|
4027
|
+
)
|
|
4028
|
+
);
|
|
4029
|
+
process.exit(1);
|
|
4030
|
+
}
|
|
4031
|
+
});
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
// src/core/canary.ts
|
|
4035
|
+
var ALPHA_UPPER_NUM = () => generateSecret({ format: "alphanumeric", length: 16 }).toUpperCase();
|
|
4036
|
+
var CANARY_FORMATS = {
|
|
4037
|
+
aws: {
|
|
4038
|
+
name: "aws",
|
|
4039
|
+
description: "AWS access key id (AKIA\u2026)",
|
|
4040
|
+
generate: () => `AKIA${ALPHA_UPPER_NUM()}`
|
|
4041
|
+
},
|
|
4042
|
+
github: {
|
|
4043
|
+
name: "github",
|
|
4044
|
+
description: "GitHub personal access token (ghp_\u2026)",
|
|
4045
|
+
generate: () => generateSecret({ format: "api-key", prefix: "ghp_", length: 36 })
|
|
4046
|
+
},
|
|
4047
|
+
openai: {
|
|
4048
|
+
name: "openai",
|
|
4049
|
+
description: "OpenAI API key (sk-\u2026)",
|
|
4050
|
+
generate: () => generateSecret({ format: "api-key", prefix: "sk-", length: 48 })
|
|
4051
|
+
},
|
|
4052
|
+
anthropic: {
|
|
4053
|
+
name: "anthropic",
|
|
4054
|
+
description: "Anthropic API key (sk-ant-\u2026)",
|
|
4055
|
+
generate: () => generateSecret({ format: "api-key", prefix: "sk-ant-api03-", length: 80 })
|
|
4056
|
+
},
|
|
4057
|
+
stripe: {
|
|
4058
|
+
name: "stripe",
|
|
4059
|
+
description: "Stripe live secret key (sk_live_\u2026)",
|
|
4060
|
+
generate: () => generateSecret({ format: "api-key", prefix: "sk_live_", length: 24 })
|
|
4061
|
+
},
|
|
4062
|
+
generic: {
|
|
4063
|
+
name: "generic",
|
|
4064
|
+
description: "Generic high-entropy API key",
|
|
4065
|
+
generate: () => generateSecret({ format: "api-key", prefix: "qk_", length: 40 })
|
|
4066
|
+
}
|
|
4067
|
+
};
|
|
4068
|
+
var DEFAULT_CANARY_FORMAT = "generic";
|
|
4069
|
+
function plantCanary(key, opts = {}) {
|
|
4070
|
+
const formatName = opts.format ?? DEFAULT_CANARY_FORMAT;
|
|
4071
|
+
const format = CANARY_FORMATS[formatName];
|
|
4072
|
+
if (!format) {
|
|
4073
|
+
throw new Error(
|
|
4074
|
+
`Unknown canary format "${formatName}". Available: ${Object.keys(CANARY_FORMATS).join(", ")}`
|
|
4075
|
+
);
|
|
4076
|
+
}
|
|
4077
|
+
const value = opts.value ?? format.generate();
|
|
4078
|
+
const scope = opts.scope ?? "global";
|
|
4079
|
+
setSecret(key, value, {
|
|
4080
|
+
...opts,
|
|
4081
|
+
scope,
|
|
4082
|
+
canary: true,
|
|
4083
|
+
canaryFormat: formatName,
|
|
4084
|
+
description: opts.value ? "Canary honeytoken (custom value)" : `Canary honeytoken (${format.description})`
|
|
4085
|
+
});
|
|
4086
|
+
return { key, value, format: formatName, scope };
|
|
4087
|
+
}
|
|
4088
|
+
function listCanaries(opts = {}) {
|
|
4089
|
+
const out = [];
|
|
4090
|
+
for (const entry of listSecrets({ ...opts, silent: true })) {
|
|
4091
|
+
const meta = entry.envelope?.meta;
|
|
4092
|
+
if (!meta?.canary) continue;
|
|
4093
|
+
out.push({
|
|
4094
|
+
key: entry.key,
|
|
4095
|
+
scope: entry.scope,
|
|
4096
|
+
format: meta.canaryFormat,
|
|
4097
|
+
plantedAt: meta.createdAt,
|
|
4098
|
+
tripCount: meta.accessCount,
|
|
4099
|
+
lastTrippedAt: meta.lastAccessedAt
|
|
4100
|
+
});
|
|
4101
|
+
}
|
|
4102
|
+
return out;
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
// src/cli/commands/canary.ts
|
|
4106
|
+
function registerCanaryCommands(program2) {
|
|
4107
|
+
const canary = program2.command("canary").description("Honeytokens \u2014 fake credentials that raise the alarm on read");
|
|
4108
|
+
canary.command("plant <key>").description("Plant a fake credential; any read fires a loud alert").option(
|
|
4109
|
+
"-f, --format <format>",
|
|
4110
|
+
`Token shape to imitate (${Object.keys(CANARY_FORMATS).join(", ")})`,
|
|
4111
|
+
DEFAULT_CANARY_FORMAT
|
|
4112
|
+
).option("--value <value>", "Plant this exact value instead of generating one").option("--force", "Overwrite an existing non-canary secret at this key").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) => {
|
|
4113
|
+
const opts = buildOpts(cmd);
|
|
4114
|
+
const existing = getEnvelope(key, opts);
|
|
4115
|
+
if (existing && !existing.envelope.meta.canary && !cmd.force) {
|
|
4116
|
+
console.error(
|
|
4117
|
+
c.red(
|
|
4118
|
+
`${SYMBOLS.cross} "${key}" already holds a real secret in ${existing.scope} scope. Use --force to replace it with a canary.`
|
|
4119
|
+
)
|
|
4120
|
+
);
|
|
4121
|
+
process.exit(1);
|
|
4122
|
+
}
|
|
4123
|
+
const result = plantCanary(key, { ...opts, format: cmd.format, value: cmd.value });
|
|
4124
|
+
console.log(
|
|
4125
|
+
`${SYMBOLS.sparkle} ${c.yellow("canary planted")} ${c.bold(result.key)} ${c.dim(`(${result.format}, ${result.scope} scope)`)}`
|
|
4126
|
+
);
|
|
4127
|
+
console.log(c.dim(` value: ${result.value}`));
|
|
4128
|
+
console.log(
|
|
4129
|
+
c.dim(
|
|
4130
|
+
" Reads return this fake value and fire a desktop alert + a 'canary' audit event."
|
|
4131
|
+
)
|
|
4132
|
+
);
|
|
4133
|
+
console.log(
|
|
4134
|
+
c.dim(" Watch trips with: qring canary list \xB7 qring audit --action canary")
|
|
4135
|
+
);
|
|
4136
|
+
});
|
|
4137
|
+
canary.command("list").alias("ls").description("List planted canaries and their trip counts").option("--json", "Output as JSON").option("-g, --global", "Global scope only").option("-p, --project", "Project scope only").option("--team <id>", "Team scope").option("--org <id>", "Org scope").option("--project-path <path>", "Project path (defaults to cwd)").action((cmd) => {
|
|
4138
|
+
const canaries = listCanaries(buildOpts(cmd));
|
|
4139
|
+
if (emitJson(program2, cmd, { canaries })) return;
|
|
4140
|
+
if (canaries.length === 0) {
|
|
4141
|
+
console.log(c.dim("No canaries planted. Plant one: qring canary plant AWS_SECRET_ACCESS_KEY --format aws"));
|
|
4142
|
+
return;
|
|
4143
|
+
}
|
|
4144
|
+
console.log(c.bold(`
|
|
4145
|
+
${SYMBOLS.eye} Canaries (${canaries.length})
|
|
4146
|
+
`));
|
|
4147
|
+
for (const entry of canaries) {
|
|
4148
|
+
const parts = [c.bold(entry.key)];
|
|
4149
|
+
parts.push(c.dim(`${entry.format ?? "generic"} \xB7 ${entry.scope}`));
|
|
4150
|
+
if (entry.tripCount > 0) {
|
|
4151
|
+
parts.push(c.red(`trips: ${entry.tripCount}`));
|
|
4152
|
+
if (entry.lastTrippedAt) {
|
|
4153
|
+
parts.push(c.red(`last: ${entry.lastTrippedAt}`));
|
|
4154
|
+
}
|
|
4155
|
+
} else {
|
|
4156
|
+
parts.push(c.green("no trips"));
|
|
4157
|
+
}
|
|
4158
|
+
console.log(` ${SYMBOLS.eye} ${parts.join(" ")}`);
|
|
4159
|
+
}
|
|
4160
|
+
console.log();
|
|
4161
|
+
});
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
// src/core/wrap.ts
|
|
4165
|
+
import { randomUUID } from "crypto";
|
|
4166
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4167
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4168
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4169
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4170
|
+
import {
|
|
4171
|
+
ListToolsRequestSchema,
|
|
4172
|
+
CallToolRequestSchema
|
|
4173
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
4174
|
+
function createAirlockServer(downstream, session) {
|
|
4175
|
+
const downstreamInfo = downstream.getServerVersion();
|
|
4176
|
+
const name = downstreamInfo ? `${downstreamInfo.name} (q-ring airlock)` : "q-ring-airlock";
|
|
4177
|
+
const proxy = new Server(
|
|
4178
|
+
{ name, version: PACKAGE_VERSION },
|
|
4179
|
+
{ capabilities: { tools: {} } }
|
|
4180
|
+
);
|
|
4181
|
+
proxy.oninitialized = () => {
|
|
4182
|
+
const info = proxy.getClientVersion();
|
|
4183
|
+
if (info) setAuditAgentLabel(`${info.name}@${info.version}`);
|
|
4184
|
+
};
|
|
4185
|
+
proxy.setRequestHandler(ListToolsRequestSchema, async (request) => {
|
|
4186
|
+
return downstream.listTools(request.params);
|
|
4187
|
+
});
|
|
4188
|
+
proxy.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
4189
|
+
const toolName = request.params.name;
|
|
4190
|
+
logAudit({
|
|
4191
|
+
action: "wrap",
|
|
4192
|
+
source: "mcp",
|
|
4193
|
+
detail: `tool call "${toolName}" \u2192 ${session.label}`,
|
|
4194
|
+
correlationId: session.correlationId
|
|
4195
|
+
});
|
|
4196
|
+
try {
|
|
4197
|
+
return await downstream.callTool(request.params, void 0, {
|
|
4198
|
+
signal: extra.signal
|
|
4199
|
+
});
|
|
4200
|
+
} catch (err) {
|
|
4201
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4202
|
+
logAudit({
|
|
4203
|
+
action: "wrap",
|
|
4204
|
+
source: "mcp",
|
|
4205
|
+
detail: `tool call "${toolName}" failed: ${message}`,
|
|
4206
|
+
correlationId: session.correlationId
|
|
4207
|
+
});
|
|
4208
|
+
return {
|
|
4209
|
+
content: [
|
|
4210
|
+
{ type: "text", text: `airlock: downstream error: ${message}` }
|
|
4211
|
+
],
|
|
4212
|
+
isError: true
|
|
4213
|
+
};
|
|
4214
|
+
}
|
|
4215
|
+
});
|
|
4216
|
+
return proxy;
|
|
4217
|
+
}
|
|
4218
|
+
async function connectDownstream(opts) {
|
|
4219
|
+
const client = new Client({
|
|
4220
|
+
name: "q-ring-airlock",
|
|
4221
|
+
version: PACKAGE_VERSION
|
|
4222
|
+
});
|
|
4223
|
+
const env = opts.inheritEnv ? Object.fromEntries(
|
|
4224
|
+
Object.entries(process.env).filter(([, v]) => v !== void 0)
|
|
4225
|
+
) : void 0;
|
|
4226
|
+
const transport = new StdioClientTransport({
|
|
4227
|
+
command: opts.command,
|
|
4228
|
+
args: opts.args ?? [],
|
|
4229
|
+
env,
|
|
4230
|
+
stderr: "inherit"
|
|
4231
|
+
});
|
|
4232
|
+
await client.connect(transport);
|
|
4233
|
+
return client;
|
|
4234
|
+
}
|
|
4235
|
+
async function runWrap(opts) {
|
|
4236
|
+
const label = opts.label ?? [opts.command, ...opts.args ?? []].join(" ");
|
|
4237
|
+
const session = { label, correlationId: randomUUID() };
|
|
4238
|
+
const downstream = await connectDownstream(opts);
|
|
4239
|
+
const downstreamInfo = downstream.getServerVersion();
|
|
4240
|
+
logAudit({
|
|
4241
|
+
action: "wrap",
|
|
4242
|
+
source: "cli",
|
|
4243
|
+
detail: `airlock session started: ${label}${opts.inheritEnv ? " (env inherited)" : " (env stripped)"}`,
|
|
4244
|
+
correlationId: session.correlationId
|
|
4245
|
+
});
|
|
4246
|
+
console.error(
|
|
4247
|
+
`q-ring airlock: wrapping ${downstreamInfo?.name ?? label} \u2014 env ${opts.inheritEnv ? "inherited" : "stripped"}, tool calls audited (session ${session.correlationId.slice(0, 8)})`
|
|
4248
|
+
);
|
|
4249
|
+
const proxy = createAirlockServer(downstream, session);
|
|
4250
|
+
const transport = new StdioServerTransport();
|
|
4251
|
+
return new Promise((resolve) => {
|
|
4252
|
+
let settled = false;
|
|
4253
|
+
const finish = (code, reason) => {
|
|
4254
|
+
if (settled) return;
|
|
4255
|
+
settled = true;
|
|
4256
|
+
logAudit({
|
|
4257
|
+
action: "wrap",
|
|
4258
|
+
source: "cli",
|
|
4259
|
+
detail: `airlock session ended: ${reason}`,
|
|
4260
|
+
correlationId: session.correlationId
|
|
4261
|
+
});
|
|
4262
|
+
void proxy.close().catch(() => {
|
|
4263
|
+
});
|
|
4264
|
+
void downstream.close().catch(() => {
|
|
4265
|
+
});
|
|
4266
|
+
resolve(code);
|
|
4267
|
+
};
|
|
4268
|
+
downstream.onclose = () => finish(1, "wrapped server exited");
|
|
4269
|
+
transport.onclose = () => finish(0, "host disconnected");
|
|
4270
|
+
transport.onerror = (err) => finish(1, `transport error: ${err.message}`);
|
|
4271
|
+
process.stdin.on("end", () => finish(0, "host disconnected"));
|
|
4272
|
+
process.stdout.on("error", (err) => {
|
|
4273
|
+
if (err.code === "EPIPE") finish(0, "host disconnected");
|
|
4274
|
+
else finish(1, `stdout error: ${err.message}`);
|
|
4275
|
+
});
|
|
4276
|
+
proxy.connect(transport).catch((err) => {
|
|
4277
|
+
console.error(
|
|
4278
|
+
`q-ring airlock: failed to start: ${err instanceof Error ? err.message : String(err)}`
|
|
4279
|
+
);
|
|
4280
|
+
finish(1, "startup failure");
|
|
4281
|
+
});
|
|
4282
|
+
});
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4285
|
+
// src/cli/commands/mcp.ts
|
|
4286
|
+
function registerMcpCommands(program2) {
|
|
4287
|
+
const mcp = program2.command("mcp").description("MCP airlock \u2014 run third-party MCP servers behind q-ring");
|
|
4288
|
+
mcp.command("wrap <command...>").description(
|
|
4289
|
+
"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"
|
|
4290
|
+
).option(
|
|
4291
|
+
"--inherit-env",
|
|
4292
|
+
"Pass the full parent environment to the wrapped server (default: minimal safe env)"
|
|
4293
|
+
).option("--label <label>", "Session label for audit events (default: the command line)").action(async (commandArgs, cmd) => {
|
|
4294
|
+
try {
|
|
4295
|
+
const code = await runWrap({
|
|
4296
|
+
command: commandArgs[0],
|
|
4297
|
+
args: commandArgs.slice(1),
|
|
4298
|
+
inheritEnv: cmd.inheritEnv === true,
|
|
4299
|
+
label: cmd.label
|
|
4300
|
+
});
|
|
4301
|
+
process.exit(code);
|
|
4302
|
+
} catch (err) {
|
|
4303
|
+
console.error(
|
|
4304
|
+
c.red(
|
|
4305
|
+
`${SYMBOLS.cross} Airlock failed: ${err instanceof Error ? err.message : String(err)}`
|
|
4306
|
+
)
|
|
4307
|
+
);
|
|
4308
|
+
process.exit(1);
|
|
4309
|
+
}
|
|
4310
|
+
});
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
// src/cli/commands/doctor.ts
|
|
4314
|
+
import { existsSync as existsSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync6, rmSync } from "fs";
|
|
4315
|
+
import { join as join5, delimiter } from "path";
|
|
4316
|
+
import { homedir as homedir2 } from "os";
|
|
3414
4317
|
function auditDir() {
|
|
3415
|
-
return process.env.QRING_AUDIT_DIR ??
|
|
4318
|
+
return process.env.QRING_AUDIT_DIR ?? join5(homedir2(), ".config", "q-ring");
|
|
3416
4319
|
}
|
|
3417
4320
|
function checkNode() {
|
|
3418
4321
|
const major = Number(process.versions.node.split(".")[0]);
|
|
@@ -3452,8 +4355,8 @@ async function checkKeyringBackend() {
|
|
|
3452
4355
|
function checkAuditLog() {
|
|
3453
4356
|
const dir = auditDir();
|
|
3454
4357
|
try {
|
|
3455
|
-
const probeFile =
|
|
3456
|
-
|
|
4358
|
+
const probeFile = join5(dir, ".doctor-probe");
|
|
4359
|
+
writeFileSync6(probeFile, "ok");
|
|
3457
4360
|
rmSync(probeFile);
|
|
3458
4361
|
} catch (err) {
|
|
3459
4362
|
return {
|
|
@@ -3480,8 +4383,8 @@ function checkAuditLog() {
|
|
|
3480
4383
|
};
|
|
3481
4384
|
}
|
|
3482
4385
|
function checkManifest(projectPath) {
|
|
3483
|
-
const manifestPath =
|
|
3484
|
-
if (!
|
|
4386
|
+
const manifestPath = join5(projectPath, ".q-ring.json");
|
|
4387
|
+
if (!existsSync6(manifestPath)) {
|
|
3485
4388
|
return {
|
|
3486
4389
|
name: "project manifest",
|
|
3487
4390
|
status: "warn",
|
|
@@ -3489,7 +4392,7 @@ function checkManifest(projectPath) {
|
|
|
3489
4392
|
};
|
|
3490
4393
|
}
|
|
3491
4394
|
try {
|
|
3492
|
-
const config = JSON.parse(
|
|
4395
|
+
const config = JSON.parse(readFileSync8(manifestPath, "utf8"));
|
|
3493
4396
|
const declared = Object.keys(config.secrets ?? {}).length;
|
|
3494
4397
|
const hasPolicy = !!config.policy;
|
|
3495
4398
|
return {
|
|
@@ -3538,11 +4441,11 @@ function checkMcpBinary() {
|
|
|
3538
4441
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
3539
4442
|
if (!dir) continue;
|
|
3540
4443
|
for (const ext of exts) {
|
|
3541
|
-
if (
|
|
4444
|
+
if (existsSync6(join5(dir, `qring-mcp${ext}`))) {
|
|
3542
4445
|
return {
|
|
3543
4446
|
name: "qring-mcp binary",
|
|
3544
4447
|
status: "ok",
|
|
3545
|
-
detail: `found at ${
|
|
4448
|
+
detail: `found at ${join5(dir, `qring-mcp${ext}`)}`
|
|
3546
4449
|
};
|
|
3547
4450
|
}
|
|
3548
4451
|
}
|
|
@@ -3749,6 +4652,11 @@ var COMMAND_GROUPS = [
|
|
|
3749
4652
|
symbol: SYMBOLS.zap,
|
|
3750
4653
|
commands: ["exec", "scan", "lint", "status", "doctor", "completion"]
|
|
3751
4654
|
},
|
|
4655
|
+
{
|
|
4656
|
+
name: "Bridges",
|
|
4657
|
+
symbol: SYMBOLS.link,
|
|
4658
|
+
commands: ["run", "setup", "push"]
|
|
4659
|
+
},
|
|
3752
4660
|
{
|
|
3753
4661
|
name: "Audit & Health",
|
|
3754
4662
|
symbol: SYMBOLS.eye,
|
|
@@ -3772,7 +4680,7 @@ var COMMAND_GROUPS = [
|
|
|
3772
4680
|
{
|
|
3773
4681
|
name: "Security & Governance",
|
|
3774
4682
|
symbol: SYMBOLS.lock,
|
|
3775
|
-
commands: ["approve", "approvals", "policy"]
|
|
4683
|
+
commands: ["approve", "approvals", "policy", "canary", "mcp"]
|
|
3776
4684
|
}
|
|
3777
4685
|
];
|
|
3778
4686
|
var GroupedHelp = class extends Help {
|
|
@@ -3889,6 +4797,9 @@ function createProgram() {
|
|
|
3889
4797
|
registerHookCommands(program2);
|
|
3890
4798
|
registerAgentCommands(program2);
|
|
3891
4799
|
registerSecurityCommands(program2);
|
|
4800
|
+
registerBridgeCommands(program2);
|
|
4801
|
+
registerCanaryCommands(program2);
|
|
4802
|
+
registerMcpCommands(program2);
|
|
3892
4803
|
registerDoctorCommand(program2);
|
|
3893
4804
|
registerCompletionCommand(program2);
|
|
3894
4805
|
return program2;
|