@i4ctime/q-ring 0.11.5 → 0.12.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/dist/mcp.js CHANGED
@@ -32,13 +32,14 @@ import {
32
32
  registry,
33
33
  remember,
34
34
  removeHook,
35
+ setPolicyRoot,
35
36
  setSecret,
36
37
  tunnelCreate,
37
38
  tunnelDestroy,
38
39
  tunnelList,
39
40
  tunnelRead,
40
41
  verifyAuditChain
41
- } from "./chunk-MQM4DLPI.js";
42
+ } from "./chunk-YWDKAZWS.js";
42
43
 
43
44
  // src/mcp.ts
44
45
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -118,24 +119,28 @@ function generateSecret(opts2 = {}) {
118
119
  }
119
120
  case "password": {
120
121
  const len = opts2.length ?? 24;
121
- let pw = randomString(PASSWORD_CHARS, len);
122
- const hasUpper = /[A-Z]/.test(pw);
123
- const hasLower = /[a-z]/.test(pw);
124
- const hasDigit = /[0-9]/.test(pw);
125
- const hasSpecial = /[^A-Za-z0-9]/.test(pw);
126
- if (!hasUpper) pw = replaceAt(pw, randomInt(len), randomString("ABCDEFGHIJKLMNOPQRSTUVWXYZ", 1));
127
- if (!hasLower) pw = replaceAt(pw, randomInt(len), randomString("abcdefghijklmnopqrstuvwxyz", 1));
128
- if (!hasDigit) pw = replaceAt(pw, randomInt(len), randomString("0123456789", 1));
129
- if (!hasSpecial) pw = replaceAt(pw, randomInt(len), randomString("!@#$%^&*()-_=+", 1));
130
- return pw;
122
+ const classCharsets = [
123
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
124
+ "abcdefghijklmnopqrstuvwxyz",
125
+ "0123456789",
126
+ "!@#$%^&*()-_=+"
127
+ ];
128
+ const guaranteed = classCharsets.slice(0, Math.min(classCharsets.length, len)).map((cs) => randomString(cs, 1));
129
+ const remaining = Math.max(0, len - guaranteed.length);
130
+ const chars = [
131
+ ...guaranteed,
132
+ ...remaining > 0 ? randomString(PASSWORD_CHARS, remaining).split("") : []
133
+ ];
134
+ for (let i = chars.length - 1; i > 0; i--) {
135
+ const j = randomInt(i + 1);
136
+ [chars[i], chars[j]] = [chars[j], chars[i]];
137
+ }
138
+ return chars.join("");
131
139
  }
132
140
  default:
133
141
  return randomBytes(32).toString("hex");
134
142
  }
135
143
  }
136
- function replaceAt(str, index, char) {
137
- return str.slice(0, index) + char + str.slice(index + 1);
138
- }
139
144
  function estimateEntropy(secret) {
140
145
  const charsets = [
141
146
  { regex: /[a-z]/, size: 26 },
@@ -173,8 +178,11 @@ function parseDotenv(content) {
173
178
  '"': '"'
174
179
  };
175
180
  value = value.replace(/\\([nrt"\\])/g, (_, ch) => escapeMap[ch] ?? ch);
176
- if (value.includes("#") && !line.includes('"') && !line.includes("'")) {
177
- value = value.split("#")[0].trim();
181
+ if (!line.includes('"') && !line.includes("'")) {
182
+ const commentMatch = value.match(/\s#/);
183
+ if (commentMatch && commentMatch.index !== void 0) {
184
+ value = value.slice(0, commentMatch.index).trim();
185
+ }
178
186
  }
179
187
  if (key) result.set(key, value);
180
188
  }
@@ -182,9 +190,14 @@ function parseDotenv(content) {
182
190
  }
183
191
  function importDotenv(filePathOrContent, options = {}) {
184
192
  let content;
185
- try {
186
- content = readFileSync(filePathOrContent, "utf8");
187
- } catch {
193
+ const source = options.source ?? "cli";
194
+ if (source === "cli") {
195
+ try {
196
+ content = readFileSync(filePathOrContent, "utf8");
197
+ } catch {
198
+ content = filePathOrContent;
199
+ }
200
+ } else {
188
201
  content = filePathOrContent;
189
202
  }
190
203
  const pairs = parseDotenv(content);
@@ -246,11 +259,21 @@ function enforceToolPolicy(toolName, projectPath8) {
246
259
  return null;
247
260
  }
248
261
  var commonSchemas = {
249
- teamId: z.string().optional().describe("Team identifier for team-scoped secrets"),
250
- orgId: z.string().optional().describe("Org identifier for org-scoped secrets"),
251
- scope: z.enum(["global", "project", "team", "org"]).optional().describe("Scope: global, project, team, or org"),
252
- projectPath: z.string().optional().describe("Project root path for project-scoped secrets"),
253
- env: z.string().optional().describe("Environment for superposition collapse (e.g., dev, staging, prod)")
262
+ teamId: z.string().optional().describe(
263
+ "Team identifier for team-scoped secrets. Required only when scope='team'. Example: 'acme-platform'."
264
+ ),
265
+ orgId: z.string().optional().describe(
266
+ "Organization identifier for org-scoped secrets. Required only when scope='org'. Example: 'acme-corp'."
267
+ ),
268
+ scope: z.enum(["global", "project", "team", "org"]).optional().describe(
269
+ "Where the secret lives. 'global' = user keyring (default if omitted on reads), 'project' = scoped to projectPath, 'team' = team-shared (needs teamId), 'org' = org-shared (needs orgId)."
270
+ ),
271
+ projectPath: z.string().optional().describe(
272
+ "Absolute path to the project root for project-scoped secrets and policy resolution. Defaults to the MCP server's current working directory when omitted."
273
+ ),
274
+ env: z.string().optional().describe(
275
+ "Environment slug used to collapse superposition when a secret has multiple per-env states. Examples: 'dev', 'staging', 'prod'. If omitted, the secret's defaultEnv is used."
276
+ )
254
277
  };
255
278
 
256
279
  // src/mcp/tools/secrets.ts
@@ -258,9 +281,15 @@ var { teamId, orgId, scope, projectPath, env } = commonSchemas;
258
281
  function registerSecretTools(server2) {
259
282
  server2.tool(
260
283
  "get_secret",
261
- "[secrets] Retrieve a secret by key. Collapses superposition if the secret has multiple environment states. Records access in audit log (observer effect).",
284
+ [
285
+ "[secrets] Read the plaintext value of a single secret from the q-ring keyring.",
286
+ "Use when an agent needs the actual credential to call an external API or inject into a runtime; prefer `inspect_secret` to see metadata only, `has_secret` for presence-only checks, and `exec_with_secrets` to run a command without exposing the value to chat.",
287
+ "Side effects: collapses superposition (selects the per-env state) and writes a 'read' event to the audit log (observer effect). Subject to project tool/key policy and may be denied with a 'Policy Denied' message. Returns JSON `{ ok, data: { key, value } }` on success or an error message if missing/blocked."
288
+ ].join(" "),
262
289
  {
263
- key: z2.string().describe("The secret key name"),
290
+ key: z2.string().describe(
291
+ "Exact secret key name as stored in the keyring (case-sensitive). Example: 'OPENAI_API_KEY'."
292
+ ),
264
293
  scope,
265
294
  projectPath,
266
295
  env,
@@ -291,14 +320,26 @@ function registerSecretTools(server2) {
291
320
  );
292
321
  server2.tool(
293
322
  "list_secrets",
294
- "[secrets] List all secret keys with quantum metadata (scope, decay status, superposition states, entanglement, access count). Values are never exposed. Supports filtering by tag, expiry state, and key pattern.",
323
+ [
324
+ "[secrets] List secret keys and quantum metadata in the requested scope, never the values.",
325
+ "Use to discover what secrets exist before reading or writing; pair with `inspect_secret` for full metadata on one key, `analyze_secrets` for usage trends, or `health_check` for decay/anomaly summaries.",
326
+ "Read-only; safe to call repeatedly. Returns JSON `{ ok, data: { entries: [...] } }` where each entry has scope, key, stateKeys (env names if superposed), expired, stale, lifetimePercent, timeRemaining, entangledCount, accessCount."
327
+ ].join(" "),
295
328
  {
296
329
  scope,
297
330
  projectPath,
298
- tag: z2.string().optional().describe("Filter by tag"),
299
- expired: z2.boolean().optional().describe("Show only expired secrets"),
300
- stale: z2.boolean().optional().describe("Show only stale secrets (75%+ decay)"),
301
- filter: z2.string().optional().describe("Glob pattern on key name (e.g., 'API_*')"),
331
+ tag: z2.string().optional().describe(
332
+ "Return only secrets that include this exact tag (case-sensitive). Example: 'production'."
333
+ ),
334
+ expired: z2.boolean().optional().describe(
335
+ "If true, return only secrets whose decay TTL has elapsed (lifetimePercent >= 100)."
336
+ ),
337
+ stale: z2.boolean().optional().describe(
338
+ "If true, return only secrets in the stale window (lifetimePercent >= 75 and not yet expired)."
339
+ ),
340
+ filter: z2.string().optional().describe(
341
+ "Glob pattern matched against the key name. Supports `*` and `?`. Examples: 'API_*', 'STRIPE_?_KEY'."
342
+ ),
302
343
  teamId,
303
344
  orgId
304
345
  },
@@ -338,18 +379,32 @@ function registerSecretTools(server2) {
338
379
  );
339
380
  server2.tool(
340
381
  "set_secret",
341
- "[secrets] Create or overwrite a secret value plus optional metadata (TTL/decay, per-env superposition, description, tags). Overwrites existing values for the same key/scope; records access in the audit log. Use import_dotenv for bulk .env ingest. Subject to tool policy; no external rate limits beyond provider calls when validating elsewhere.",
382
+ [
383
+ "[secrets] Create or overwrite a single secret value, optionally with TTL/decay, per-env superposition, description, tags, and rotation hints.",
384
+ "Use to add or update one key at a time; prefer `import_dotenv` for bulk .env ingest, `generate_secret` (with saveAs) to generate-and-store in one step, and `entangle_secrets` instead of duplicating the same value under two keys.",
385
+ "Mutates the keyring (overwrites any existing value at the same key/scope), writes a 'write' event to the audit log, and triggers any matching hooks. Subject to tool policy. Returns a short confirmation text like '[scope] KEY saved' (or '[scope] KEY set for env:NAME' when `env` is provided)."
386
+ ].join(" "),
342
387
  {
343
- key: z2.string().describe("The secret key name"),
344
- value: z2.string().describe("The secret value"),
388
+ key: z2.string().describe(
389
+ "Secret key name (UPPER_SNAKE_CASE recommended). Example: 'STRIPE_SECRET_KEY'."
390
+ ),
391
+ value: z2.string().describe(
392
+ "The secret value to store. Stored as-is; never logged or echoed. May be empty only when `env` is provided to register a new env without a default."
393
+ ),
345
394
  scope: scope.default("global"),
346
395
  projectPath,
347
396
  env: z2.string().optional().describe(
348
- "If provided, sets the value for this specific environment (superposition)"
397
+ "If set, writes this value to the named per-env state (superposition) instead of the default slot. Existing default value is preserved as state 'default'. Example: 'prod'."
398
+ ),
399
+ ttlSeconds: z2.number().optional().describe(
400
+ "Quantum decay window in seconds. After this many seconds the secret is marked expired (still readable, but `has_secret` returns false and `health_check` flags it). Omit for no decay."
401
+ ),
402
+ description: z2.string().optional().describe(
403
+ "Free-text human-readable description shown in `inspect_secret` and the dashboard."
404
+ ),
405
+ tags: z2.array(z2.string()).optional().describe(
406
+ "Tag list for filtering and hook matching. Example: ['production', 'payments']."
349
407
  ),
350
- ttlSeconds: z2.number().optional().describe("Time-to-live in seconds (quantum decay)"),
351
- description: z2.string().optional().describe("Human-readable description"),
352
- tags: z2.array(z2.string()).optional().describe("Tags for organization"),
353
408
  rotationFormat: z2.enum([
354
409
  "hex",
355
410
  "base64",
@@ -358,8 +413,12 @@ function registerSecretTools(server2) {
358
413
  "api-key",
359
414
  "token",
360
415
  "password"
361
- ]).optional().describe("Format for auto-rotation when this secret expires"),
362
- rotationPrefix: z2.string().optional().describe("Prefix for auto-rotation (e.g. 'sk-')"),
416
+ ]).optional().describe(
417
+ "Format used by `agent_scan --autoRotate` and `rotate_secret` when this secret expires. Pick the format that matches the upstream service's accepted shape."
418
+ ),
419
+ rotationPrefix: z2.string().optional().describe(
420
+ "Literal prefix prepended on auto-rotation (only used with rotationFormat 'api-key' or 'token'). Example: 'sk-'."
421
+ ),
363
422
  teamId,
364
423
  orgId
365
424
  },
@@ -401,9 +460,13 @@ function registerSecretTools(server2) {
401
460
  );
402
461
  server2.tool(
403
462
  "delete_secret",
404
- "[secrets] Permanently remove a secret value from the keyring for the given scope/path (not recoverable from q-ring). Does not remove hooks, tunnels, or entanglement metadata alone\u2014use remove_hook, tunnel_destroy, or disentangle_secrets respectively. Returns success or not-found text; subject to tool policy.",
463
+ [
464
+ "[secrets] Permanently remove a secret value (and all its env states) from the keyring for the given scope.",
465
+ "Use when a credential is being retired or was created in error; prefer `disentangle_secrets` to break a sync link without erasing values, `remove_hook` to detach lifecycle callbacks, and `tunnel_destroy` for ephemeral tunnels.",
466
+ `Destructive and not undoable from q-ring (no built-in trash). Writes a 'delete' event to the audit log and fires matching hooks. Returns 'Deleted "KEY"' on success or a not-found error if the key did not exist in the requested scope. Subject to tool policy.`
467
+ ].join(" "),
405
468
  {
406
- key: z2.string().describe("The secret key name"),
469
+ key: z2.string().describe("Exact secret key name to delete. Example: 'OLD_API_KEY'."),
407
470
  scope,
408
471
  projectPath,
409
472
  teamId,
@@ -421,9 +484,13 @@ function registerSecretTools(server2) {
421
484
  );
422
485
  server2.tool(
423
486
  "has_secret",
424
- "[secrets] Check if a secret exists. Returns boolean. Never reveals the value. Respects decay \u2014 expired secrets return false.",
487
+ [
488
+ "[secrets] Check whether a secret exists in the requested scope without reading the value.",
489
+ "Use as a cheap precondition before reading or writing \u2014 for example, to skip prompting the user for a key that is already configured. Prefer `inspect_secret` when you also need metadata.",
490
+ "Read-only; does not record a 'read' in the audit log. Decay-aware: returns 'false' for expired secrets even though the value is still in the store. Returns the literal text 'true' or 'false'."
491
+ ].join(" "),
425
492
  {
426
- key: z2.string().describe("The secret key name"),
493
+ key: z2.string().describe("Exact secret key name. Example: 'GITHUB_TOKEN'."),
427
494
  scope,
428
495
  projectPath,
429
496
  teamId,
@@ -437,11 +504,21 @@ function registerSecretTools(server2) {
437
504
  );
438
505
  server2.tool(
439
506
  "export_secrets",
440
- "[secrets] Export secrets as .env or JSON format. Collapses superposition. Supports filtering by specific keys or tags.",
507
+ [
508
+ "[secrets] Render multiple secrets as a single .env or JSON document for piping into another tool or file.",
509
+ "Use to materialize secrets for a one-off export or copy; prefer `env_generate` when you want output driven by the project's `.q-ring.json` manifest, and `teleport_pack` for an encrypted bundle to share between machines.",
510
+ "Reads values (collapses superposition for the requested env) and writes one 'export' event per included secret to the audit log. Returns the rendered text directly (no JSON wrapper). Returns an error if no secrets matched the filters. Values are surfaced in plaintext \u2014 handle with care."
511
+ ].join(" "),
441
512
  {
442
- format: z2.enum(["env", "json"]).optional().default("env").describe("Output format"),
443
- keys: z2.array(z2.string()).optional().describe("Only export these specific key names"),
444
- tags: z2.array(z2.string()).optional().describe("Only export secrets with any of these tags"),
513
+ format: z2.enum(["env", "json"]).optional().default("env").describe(
514
+ `'env' renders KEY="value" lines suitable for a .env file; 'json' renders an object keyed by secret name. Defaults to 'env'.`
515
+ ),
516
+ keys: z2.array(z2.string()).optional().describe(
517
+ "Whitelist of exact key names to include. If omitted, every key in scope is considered (subject to `tags`)."
518
+ ),
519
+ tags: z2.array(z2.string()).optional().describe(
520
+ "Include only secrets tagged with at least one of these tags. Combined with `keys` as an AND filter when both are supplied."
521
+ ),
445
522
  scope,
446
523
  projectPath,
447
524
  env,
@@ -463,13 +540,23 @@ function registerSecretTools(server2) {
463
540
  );
464
541
  server2.tool(
465
542
  "import_dotenv",
466
- "[secrets] Import secrets from .env file content. Parses standard dotenv syntax (comments, quotes, multiline escapes) and stores each key/value pair in q-ring.",
543
+ [
544
+ "[secrets] Parse standard dotenv-formatted text and store each key/value pair into the keyring in one batch.",
545
+ "Use when migrating an existing `.env` file into q-ring or onboarding a new project; prefer `set_secret` for a single key, and `teleport_unpack` to import an encrypted bundle.",
546
+ "Mutates the keyring (one write per parsed key) and emits a 'write' audit event for each. Supports comments, single/double quotes, and `\\n` escapes. Returns a multiline summary listing imported keys and any skipped (existing) keys; in dryRun mode no writes happen and the same summary is produced for review."
547
+ ].join(" "),
467
548
  {
468
- content: z2.string().describe("The .env file content to parse and import"),
549
+ content: z2.string().describe(
550
+ "Raw .env file content as a single string (newline-separated KEY=VALUE lines, comments allowed)."
551
+ ),
469
552
  scope: scope.default("global"),
470
553
  projectPath,
471
- skipExisting: z2.boolean().optional().default(false).describe("Skip keys that already exist in q-ring"),
472
- dryRun: z2.boolean().optional().default(false).describe("Preview what would be imported without saving")
554
+ skipExisting: z2.boolean().optional().default(false).describe(
555
+ "If true, leave already-present keys untouched and add them to the 'skipped' list instead of overwriting."
556
+ ),
557
+ dryRun: z2.boolean().optional().default(false).describe(
558
+ "If true, parse and report what would happen but do not write to the keyring. Useful for previewing imports before committing."
559
+ )
473
560
  },
474
561
  async (params) => {
475
562
  const toolBlock = enforceToolPolicy("import_dotenv", params.projectPath);
@@ -495,9 +582,13 @@ function registerSecretTools(server2) {
495
582
  );
496
583
  server2.tool(
497
584
  "inspect_secret",
498
- "[secrets] Show full quantum state of a secret: superposition states, decay status, entanglement links, access history. Never reveals the actual value.",
585
+ [
586
+ "[secrets] Show full metadata for a single secret \u2014 env states, decay window, entanglement links, access counters \u2014 without ever revealing the value.",
587
+ "Use when you need to understand the shape of a key before reading it or to debug 'why is this expired/stale'; prefer `get_secret` for the actual value, `list_secrets` for a many-key overview, and `audit_log` for the full access timeline.",
588
+ "Read-only; does not write a 'read' event since the value is not exposed. Returns pretty-printed JSON with fields: key, scope, type ('superposition'|'collapsed'), created, updated, accessCount, lastAccessed, environments, defaultEnv, decay { expired, stale, lifetimePercent, timeRemaining }, entangled, description, tags. Errors with not-found if the key is absent."
589
+ ].join(" "),
499
590
  {
500
- key: z2.string().describe("The secret key name"),
591
+ key: z2.string().describe("Exact secret key name to inspect. Example: 'OPENAI_API_KEY'."),
501
592
  scope,
502
593
  projectPath,
503
594
  teamId,
@@ -542,7 +633,11 @@ function registerSecretTools(server2) {
542
633
  );
543
634
  server2.tool(
544
635
  "generate_secret",
545
- "[secrets] Generate a cryptographic secret (quantum noise). Formats: hex, base64, alphanumeric, uuid, api-key, token, password. Optionally save directly to the keyring.",
636
+ [
637
+ "[secrets] Generate a cryptographically random secret using Node's CSPRNG and optionally store it in the keyring in one step.",
638
+ "Use to create new credentials that you control (signing keys, internal tokens, passwords); for issuer-issued credentials (Stripe/OpenAI etc.) use `rotate_secret` to ask the upstream provider for a fresh key, and use `set_secret` for values you already have in hand.",
639
+ "If `saveAs` is provided this mutates the keyring (one 'write' event) and returns a summary like 'Generated and saved as \"KEY\" (FORMAT, ~N bits entropy)'. Without `saveAs` the call is read-only and returns JSON `{ ok, data: { value } }` containing the freshly generated string."
640
+ ].join(" "),
546
641
  {
547
642
  format: z2.enum([
548
643
  "hex",
@@ -552,10 +647,18 @@ function registerSecretTools(server2) {
552
647
  "api-key",
553
648
  "token",
554
649
  "password"
555
- ]).optional().default("api-key").describe("Output format"),
556
- length: z2.number().optional().describe("Length in bytes or characters"),
557
- prefix: z2.string().optional().describe("Prefix for api-key/token format"),
558
- saveAs: z2.string().optional().describe("If provided, save the generated secret with this key name"),
650
+ ]).optional().default("api-key").describe(
651
+ "Output shape. 'hex' / 'base64' / 'alphanumeric' = raw random string of `length` characters; 'uuid' = RFC4122 v4; 'api-key' / 'token' = random alphanumeric with optional `prefix`; 'password' = mixed-case alphanumeric with symbols. Defaults to 'api-key'."
652
+ ),
653
+ length: z2.number().optional().describe(
654
+ "Number of characters (or bytes for hex/base64) to generate. Ignored for 'uuid'. Defaults to a sensible per-format value (e.g. 32 for api-key)."
655
+ ),
656
+ prefix: z2.string().optional().describe(
657
+ "Literal prefix prepended to the random portion. Only meaningful for 'api-key' and 'token'. Example: 'sk-' or 'svc_'."
658
+ ),
659
+ saveAs: z2.string().optional().describe(
660
+ "If provided, store the generated value at this key name in the keyring (one mutation). Omit to just return the value without persisting."
661
+ ),
559
662
  scope: scope.default("global"),
560
663
  projectPath,
561
664
  teamId,
@@ -584,14 +687,22 @@ function registerSecretTools(server2) {
584
687
  );
585
688
  server2.tool(
586
689
  "entangle_secrets",
587
- "[secrets] Link two keys so source updates/rotations propagate the same value to the target (mutates metadata; future writes sync). Reverse with disentangle_secrets without deleting values; do not confuse with set_secret (single-key write). Subject to tool policy.",
690
+ [
691
+ "[secrets] Link two keys (across the same or different scopes) so future writes/rotations of either propagate the same value to the other.",
692
+ "Use when one logical credential lives under multiple names (e.g. `STRIPE_SECRET_KEY` global and project) and should never drift; prefer `set_secret` for unrelated values, and reverse the link with `disentangle_secrets` (does not delete values).",
693
+ "Mutates only the metadata of both envelopes \u2014 the values themselves are not changed by this call. Idempotent: re-running on an already-entangled pair is a no-op. Subject to tool policy. Returns a short confirmation: 'Entangled: SOURCE <-> TARGET'."
694
+ ].join(" "),
588
695
  {
589
- sourceKey: z2.string().describe("Source secret key"),
590
- targetKey: z2.string().describe("Target secret key"),
696
+ sourceKey: z2.string().describe("First secret key in the pair. Example: 'STRIPE_SECRET_KEY'."),
697
+ targetKey: z2.string().describe("Second secret key to keep in lockstep with the source."),
591
698
  sourceScope: scope.default("global"),
592
699
  targetScope: scope.default("global"),
593
- sourceProjectPath: z2.string().optional(),
594
- targetProjectPath: z2.string().optional()
700
+ sourceProjectPath: z2.string().optional().describe(
701
+ "Project root for sourceKey when sourceScope='project'. Defaults to the server cwd."
702
+ ),
703
+ targetProjectPath: z2.string().optional().describe(
704
+ "Project root for targetKey when targetScope='project'. Defaults to the server cwd."
705
+ )
595
706
  },
596
707
  async (params) => {
597
708
  const toolBlock = enforceToolPolicy(
@@ -618,14 +729,18 @@ function registerSecretTools(server2) {
618
729
  );
619
730
  server2.tool(
620
731
  "disentangle_secrets",
621
- "[secrets] Remove the sync link between two keys so rotations stop propagating. Does not delete either secret\u2014use delete_secret to erase values. Contrast entangle_secrets (creates link). Safe if the link was already absent; updates metadata; subject to tool policy.",
732
+ [
733
+ "[secrets] Break the sync link between two previously entangled keys so future rotations no longer propagate.",
734
+ "Use when one of the keys is being retired or should diverge intentionally; pair with `delete_secret` if you also want to erase one of the values, and use `entangle_secrets` to recreate the link.",
735
+ "Mutates only metadata; the current values remain untouched. Safe and idempotent \u2014 running on a pair that was never linked returns success without effect. Subject to tool policy. Returns 'Disentangled: SOURCE </> TARGET'."
736
+ ].join(" "),
622
737
  {
623
- sourceKey: z2.string().describe("Source secret key"),
624
- targetKey: z2.string().describe("Target secret key"),
738
+ sourceKey: z2.string().describe("First key in the previously linked pair."),
739
+ targetKey: z2.string().describe("Second key in the previously linked pair."),
625
740
  sourceScope: scope.default("global"),
626
741
  targetScope: scope.default("global"),
627
- sourceProjectPath: z2.string().optional(),
628
- targetProjectPath: z2.string().optional()
742
+ sourceProjectPath: z2.string().optional().describe("Project root for sourceKey when sourceScope='project'."),
743
+ targetProjectPath: z2.string().optional().describe("Project root for targetKey when targetScope='project'.")
629
744
  },
630
745
  async (params) => {
631
746
  const toolBlock = enforceToolPolicy(
@@ -934,7 +1049,11 @@ var { teamId: teamId2, orgId: orgId2, scope: scope2, projectPath: projectPath2,
934
1049
  function registerProjectTools(server2) {
935
1050
  server2.tool(
936
1051
  "check_project",
937
- "[project] Validate project secrets against the .q-ring.json manifest. Returns which required secrets are present, missing, expired, or stale. Use this to verify project readiness.",
1052
+ [
1053
+ "[project] Compare the keys declared in the project's `.q-ring.json` manifest against what is actually present in the keyring.",
1054
+ "Use as the canonical 'is this project ready to run' gate before starting a dev server, deploying, or onboarding a teammate; prefer `health_check` for a scope-wide decay sweep (no manifest), and `agent_scan` for multi-project scans with optional auto-rotation.",
1055
+ "Read-only; does not mutate the keyring or audit log materially beyond a 'list' read. Returns JSON `{ total, present, missing, expired, stale, ready, secrets: [...] }` where `ready` is true only when nothing is missing or expired. Errors with 'No secrets manifest found in .q-ring.json' if the project has no manifest."
1056
+ ].join(" "),
938
1057
  {
939
1058
  projectPath: projectPath2
940
1059
  },
@@ -1001,7 +1120,11 @@ function registerProjectTools(server2) {
1001
1120
  );
1002
1121
  server2.tool(
1003
1122
  "env_generate",
1004
- "[project] Generate .env file content from the project manifest (.q-ring.json). Resolves each declared secret from q-ring, collapses superposition, and returns .env formatted output. Warns about missing or expired secrets.",
1123
+ [
1124
+ "[project] Render a complete `.env` file body from the project's `.q-ring.json` manifest, resolving each declared key from the keyring.",
1125
+ "Use when a build step or local runtime needs a real `.env` materialized on disk and you want exactly the keys the manifest declares; prefer `export_secrets` when you want every key in scope (manifest-agnostic) and `exec_with_secrets` to inject secrets into a child process without writing them to a file.",
1126
+ "Reads values (records 'read' audit events) and collapses superposition for the requested env. Returns the raw `.env` text, with `# MISSING (required): KEY` / `# EXPIRED: KEY` / `# STALE: KEY` warnings appended as comments. Missing keys appear as commented-out `# KEY=` placeholders so the file remains a valid drop-in."
1127
+ ].join(" "),
1005
1128
  {
1006
1129
  projectPath: projectPath2,
1007
1130
  env: env2
@@ -1048,7 +1171,11 @@ ${warnings.map((w) => `# ${w}`).join("\n")}` : output;
1048
1171
  );
1049
1172
  server2.tool(
1050
1173
  "detect_environment",
1051
- "[project] Detect the current environment context (wavefunction collapse). Returns the detected environment and its source (NODE_ENV, git branch, project config, etc.).",
1174
+ [
1175
+ "[project] Resolve which environment slug (e.g. 'dev', 'staging', 'prod') the current invocation should collapse to.",
1176
+ "Use before reading secrets when you want to mirror the same env q-ring would auto-pick (e.g. to log it, or to pass through to another tool); prefer passing an explicit `env` to `get_secret`/`env_generate` when you already know which env you want.",
1177
+ "Read-only; checks the QRING_ENV env var, NODE_ENV, the project's `.q-ring.json`, and the current git branch in priority order. Returns JSON `{ env, source }` (e.g. `{ env: 'dev', source: 'NODE_ENV' }`), or a plain message indicating that no env could be detected."
1178
+ ].join(" "),
1052
1179
  {
1053
1180
  projectPath: projectPath2
1054
1181
  },
@@ -1071,7 +1198,11 @@ ${warnings.map((w) => `# ${w}`).join("\n")}` : output;
1071
1198
  );
1072
1199
  server2.tool(
1073
1200
  "get_project_context",
1074
- "[agent] Get a safe, redacted overview of the project's secrets, environment, manifest, providers, hooks, and recent audit activity. No secret values are ever exposed. Use this to understand what secrets exist before asking to read them.",
1201
+ [
1202
+ "[agent] Return a single redacted snapshot of everything an AI agent typically wants to know about this project: secrets present (keys + metadata only), detected env, manifest declarations, configured providers, registered hooks, and recent audit activity.",
1203
+ "Use this as the very first call in a session to orient the agent before it asks for any individual secret; prefer `list_secrets` for a flat key listing, `check_project` for manifest-vs-keyring drift, and `audit_log` for a deeper access trail.",
1204
+ "Read-only and value-safe \u2014 no plaintext secret values are ever included. Returns a single pretty-printed JSON document; shape is intentionally broad and may grow over time, so read defensively."
1205
+ ].join(" "),
1075
1206
  {
1076
1207
  scope: scope2,
1077
1208
  projectPath: projectPath2,
@@ -1095,11 +1226,21 @@ import { z as z3 } from "zod";
1095
1226
  function registerTunnelTools(server2) {
1096
1227
  server2.tool(
1097
1228
  "tunnel_create",
1098
- "[tunnel] Create an ephemeral secret that exists only in memory (quantum tunneling). Never persisted to disk. Optional TTL and max-reads for self-destruction.",
1229
+ [
1230
+ "[tunnel] Stash a one-shot or short-lived secret in the q-ring server's process memory and return an ID that can be used to read it back.",
1231
+ "Use for handing a one-time value to another tool/process without persisting it (npm OTP codes, magic-link tokens, copy/paste between machines via a relay); prefer `set_secret` with `ttlSeconds` when you actually want a tracked, auditable secret.",
1232
+ "Mutates only in-memory state \u2014 the value never touches disk and is lost on server restart. Subject to tool policy. Returns JSON `{ ok, data: { id } }` where `id` is an opaque string to pass to `tunnel_read`/`tunnel_destroy`."
1233
+ ].join(" "),
1099
1234
  {
1100
- value: z3.string().describe("The secret value"),
1101
- ttlSeconds: z3.number().optional().describe("Auto-expire after N seconds"),
1102
- maxReads: z3.number().optional().describe("Self-destruct after N reads")
1235
+ value: z3.string().describe(
1236
+ "The plaintext value to tunnel. Held only in process memory; never logged."
1237
+ ),
1238
+ ttlSeconds: z3.number().optional().describe(
1239
+ "Auto-destroy the tunnel after this many seconds. Omit for no time limit (then a `maxReads` is highly recommended)."
1240
+ ),
1241
+ maxReads: z3.number().optional().describe(
1242
+ "Self-destruct after this many successful `tunnel_read` calls. Use 1 for true one-shot delivery."
1243
+ )
1103
1244
  },
1104
1245
  async (params) => {
1105
1246
  const toolBlock = enforceToolPolicy("tunnel_create");
@@ -1113,9 +1254,15 @@ function registerTunnelTools(server2) {
1113
1254
  );
1114
1255
  server2.tool(
1115
1256
  "tunnel_read",
1116
- "[tunnel] Read an ephemeral tunneled secret by ID. May self-destruct if max-reads is reached.",
1257
+ [
1258
+ "[tunnel] Fetch the value stashed by a prior `tunnel_create` call by its ID.",
1259
+ "Use exactly once per intended consumer; the value is destructive-by-design and may self-delete after this call.",
1260
+ "Increments the read counter and may auto-destroy the tunnel if `maxReads` was set. Returns JSON `{ ok, data: { id, value } }` on success, or an error 'Tunnel \"...\" not found or expired' if the tunnel has been destroyed, hit its TTL, or never existed."
1261
+ ].join(" "),
1117
1262
  {
1118
- id: z3.string().describe("Tunnel ID")
1263
+ id: z3.string().describe(
1264
+ "The opaque tunnel ID returned by `tunnel_create`. Case-sensitive."
1265
+ )
1119
1266
  },
1120
1267
  async (params) => {
1121
1268
  const toolBlock = enforceToolPolicy("tunnel_read");
@@ -1131,7 +1278,11 @@ function registerTunnelTools(server2) {
1131
1278
  );
1132
1279
  server2.tool(
1133
1280
  "tunnel_list",
1134
- "[tunnel] List active tunneled secrets (IDs and metadata only, never values).",
1281
+ [
1282
+ "[tunnel] Enumerate all currently-active tunnels in the q-ring server with their remaining read budget and time-to-live.",
1283
+ "Use to audit what is still in memory or to look up an ID you forgot; values are never included in the output.",
1284
+ "Read-only. Returns one line per tunnel formatted as `id | reads:N | max:N | expires:Ns`, or the literal text 'No active tunnels' when the list is empty."
1285
+ ].join(" "),
1135
1286
  {},
1136
1287
  async () => {
1137
1288
  const toolBlock = enforceToolPolicy("tunnel_list");
@@ -1156,9 +1307,13 @@ function registerTunnelTools(server2) {
1156
1307
  );
1157
1308
  server2.tool(
1158
1309
  "tunnel_destroy",
1159
- "[tunnel] Immediately destroy a tunneled secret.",
1310
+ [
1311
+ "[tunnel] Immediately remove a tunnel from memory, regardless of remaining reads or TTL.",
1312
+ "Use when a tunneled value should be cancelled before delivery (e.g. wrong recipient, secret already rotated); prefer letting `maxReads`/TTL handle cleanup for normal flows.",
1313
+ "Mutates in-memory state only. Returns 'Destroyed ID' on success or a not-found error if the ID is unknown or already gone."
1314
+ ].join(" "),
1160
1315
  {
1161
- id: z3.string().describe("Tunnel ID")
1316
+ id: z3.string().describe("The opaque tunnel ID to destroy.")
1162
1317
  },
1163
1318
  async (params) => {
1164
1319
  const toolBlock = enforceToolPolicy("tunnel_destroy");
@@ -1187,7 +1342,8 @@ var ALGORITHM = "aes-256-gcm";
1187
1342
  var KEY_LENGTH = 32;
1188
1343
  var IV_LENGTH = 12;
1189
1344
  var SALT_LENGTH = 32;
1190
- var PBKDF2_ITERATIONS = 1e5;
1345
+ var PBKDF2_ITERATIONS = 21e4;
1346
+ var LEGACY_PBKDF2_ITERATIONS = 1e5;
1191
1347
  var TeleportBundleSchema = z4.object({
1192
1348
  v: z4.literal(1),
1193
1349
  data: z4.string(),
@@ -1195,7 +1351,8 @@ var TeleportBundleSchema = z4.object({
1195
1351
  iv: z4.string(),
1196
1352
  tag: z4.string(),
1197
1353
  createdAt: z4.string(),
1198
- count: z4.number()
1354
+ count: z4.number(),
1355
+ iter: z4.number().optional()
1199
1356
  });
1200
1357
  var TeleportPayloadSchema = z4.object({
1201
1358
  secrets: z4.array(
@@ -1208,8 +1365,8 @@ var TeleportPayloadSchema = z4.object({
1208
1365
  exportedAt: z4.string(),
1209
1366
  exportedBy: z4.string().optional()
1210
1367
  });
1211
- function deriveKey(passphrase, salt) {
1212
- return pbkdf2Sync(passphrase, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha512");
1368
+ function deriveKey(passphrase, salt, iterations = PBKDF2_ITERATIONS) {
1369
+ return pbkdf2Sync(passphrase, salt, iterations, KEY_LENGTH, "sha512");
1213
1370
  }
1214
1371
  function teleportPack(secrets, passphrase) {
1215
1372
  const payload = {
@@ -1219,7 +1376,7 @@ function teleportPack(secrets, passphrase) {
1219
1376
  const plaintext = JSON.stringify(payload);
1220
1377
  const salt = randomBytes2(SALT_LENGTH);
1221
1378
  const iv = randomBytes2(IV_LENGTH);
1222
- const key = deriveKey(passphrase, salt);
1379
+ const key = deriveKey(passphrase, salt, PBKDF2_ITERATIONS);
1223
1380
  const cipher = createCipheriv(ALGORITHM, key, iv);
1224
1381
  const encrypted = Buffer.concat([
1225
1382
  cipher.update(plaintext, "utf8"),
@@ -1233,7 +1390,8 @@ function teleportPack(secrets, passphrase) {
1233
1390
  iv: iv.toString("base64"),
1234
1391
  tag: tag.toString("base64"),
1235
1392
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1236
- count: secrets.length
1393
+ count: secrets.length,
1394
+ iter: PBKDF2_ITERATIONS
1237
1395
  };
1238
1396
  return Buffer.from(JSON.stringify(bundle)).toString("base64");
1239
1397
  }
@@ -1261,7 +1419,7 @@ function teleportUnpack(encoded, passphrase) {
1261
1419
  const iv = Buffer.from(bundle.iv, "base64");
1262
1420
  const tag = Buffer.from(bundle.tag, "base64");
1263
1421
  const encrypted = Buffer.from(bundle.data, "base64");
1264
- const key = deriveKey(passphrase, salt);
1422
+ const key = deriveKey(passphrase, salt, bundle.iter ?? LEGACY_PBKDF2_ITERATIONS);
1265
1423
  const decipher = createDecipheriv(ALGORITHM, key, iv);
1266
1424
  decipher.setAuthTag(tag);
1267
1425
  let decrypted;
@@ -1293,10 +1451,18 @@ var { teamId: teamId3, orgId: orgId3, scope: scope3, projectPath: projectPath3 }
1293
1451
  function registerTeleportTools(server2) {
1294
1452
  server2.tool(
1295
1453
  "teleport_pack",
1296
- "[teleport] Pack secrets into an AES-256-GCM encrypted bundle for sharing between machines (quantum teleportation).",
1454
+ [
1455
+ "[teleport] Encrypt one or more secrets into a single AES-256-GCM bundle string that can be safely transferred between machines.",
1456
+ "Use to hand off a curated set of credentials to another developer or environment; prefer `export_secrets` for plaintext .env output (single machine, trusted) and `tunnel_create` for ephemeral one-shot delivery on the same machine.",
1457
+ "Reads each secret value (records 'export' audit events) and produces a base64-encoded ciphertext. The bundle is unreadable without the same passphrase via `teleport_unpack`. Returns the bundle string directly. Errors with 'No secrets to pack' if the filter matched zero secrets."
1458
+ ].join(" "),
1297
1459
  {
1298
- keys: z5.array(z5.string()).optional().describe("Specific keys to pack (all if omitted)"),
1299
- passphrase: z5.string().describe("Encryption passphrase"),
1460
+ keys: z5.array(z5.string()).optional().describe(
1461
+ "Whitelist of exact key names to include. Omit to pack every secret in the requested scope."
1462
+ ),
1463
+ passphrase: z5.string().describe(
1464
+ "Symmetric passphrase used to derive the AES-256-GCM key. The receiver must supply the same string to `teleport_unpack`. Pick something high-entropy and share it out-of-band."
1465
+ ),
1300
1466
  scope: scope3,
1301
1467
  projectPath: projectPath3,
1302
1468
  teamId: teamId3,
@@ -1322,15 +1488,25 @@ function registerTeleportTools(server2) {
1322
1488
  );
1323
1489
  server2.tool(
1324
1490
  "teleport_unpack",
1325
- "[teleport] Decrypt and import secrets from a teleport bundle.",
1491
+ [
1492
+ "[teleport] Decrypt a bundle produced by `teleport_pack` and import each contained secret into the local keyring.",
1493
+ "Use on the receiving machine after a packer hands you the bundle and passphrase out-of-band; prefer `dryRun=true` first to preview what will be written.",
1494
+ "When dryRun is false this mutates the keyring (one 'write' event per imported secret) at the requested scope. Bad passphrase or tampered bundle returns JSON `{ ok: false, error: { message } }` with `isError: true`. On success returns 'Imported N secret(s) from teleport bundle'; in dryRun mode returns 'Would import N secrets:' followed by a `KEY [scope]` listing."
1495
+ ].join(" "),
1326
1496
  {
1327
- bundle: z5.string().describe("Base64-encoded encrypted bundle"),
1328
- passphrase: z5.string().describe("Decryption passphrase"),
1497
+ bundle: z5.string().describe(
1498
+ "Base64-encoded ciphertext returned by `teleport_pack`. Pass through whitespace untouched if possible."
1499
+ ),
1500
+ passphrase: z5.string().describe(
1501
+ "The same passphrase that was used to pack this bundle. Bad passphrases return an authentication error rather than wrong plaintext."
1502
+ ),
1329
1503
  scope: scope3.default("global"),
1330
1504
  projectPath: projectPath3,
1331
1505
  teamId: teamId3,
1332
1506
  orgId: orgId3,
1333
- dryRun: z5.boolean().optional().default(false).describe("Preview without importing")
1507
+ dryRun: z5.boolean().optional().default(false).describe(
1508
+ "If true, decrypt and report what would be written but do not mutate the keyring. Useful for verifying bundle contents before commit."
1509
+ )
1334
1510
  },
1335
1511
  async (params) => {
1336
1512
  const toolBlock = enforceToolPolicy("teleport_unpack", params.projectPath);
@@ -1365,9 +1541,15 @@ var { teamId: teamId4, orgId: orgId4, scope: scope4, projectPath: projectPath4 }
1365
1541
  function registerAuditTools(server2) {
1366
1542
  server2.tool(
1367
1543
  "audit_log",
1368
- "[audit] Query the audit log for secret access history (observer effect). Shows who accessed what and when.",
1544
+ [
1545
+ "[audit] Query the q-ring audit log \u2014 a tamper-evident record of every read/write/delete touching a secret.",
1546
+ "Use to investigate 'who accessed KEY recently?' or to feed an agent the access timeline for a specific credential; prefer `detect_anomalies` for automated unusual-pattern detection and `health_check` for decay-state-plus-anomalies in one call.",
1547
+ "Read-only. Returns one line per event in chronological order, formatted `timestamp | action | key | [scope] | env:NAME | detail`. Returns 'No audit events found' when the filter matches nothing."
1548
+ ].join(" "),
1369
1549
  {
1370
- key: z6.string().optional().describe("Filter by key"),
1550
+ key: z6.string().optional().describe(
1551
+ "Limit to events touching this exact key. Omit for the full log."
1552
+ ),
1371
1553
  action: z6.enum([
1372
1554
  "read",
1373
1555
  "write",
@@ -1379,8 +1561,12 @@ function registerAuditTools(server2) {
1379
1561
  "tunnel",
1380
1562
  "teleport",
1381
1563
  "collapse"
1382
- ]).optional().describe("Filter by action"),
1383
- limit: z6.number().optional().default(20).describe("Max events to return")
1564
+ ]).optional().describe(
1565
+ "Limit to a single action verb (e.g. 'read' to see only reads). Omit for all actions."
1566
+ ),
1567
+ limit: z6.number().optional().default(20).describe(
1568
+ "Maximum events to return, newest first. Defaults to 20. Increase for deeper investigations."
1569
+ )
1384
1570
  },
1385
1571
  async (params) => {
1386
1572
  const toolBlock = enforceToolPolicy("audit_log");
@@ -1404,9 +1590,15 @@ function registerAuditTools(server2) {
1404
1590
  );
1405
1591
  server2.tool(
1406
1592
  "detect_anomalies",
1407
- "[audit] Read-only scan of audit history for burst reads and unusual-hour access (text lines per finding). Optional key filter. Use health_check for full decay inventory + anomaly count in scope; use agent_scan for multi-project JSON reports or optional auto-rotation. Does not mutate secrets.",
1593
+ [
1594
+ "[audit] Scan the audit history for suspicious access patterns \u2014 burst reads of the same key, off-hours access, and other heuristics.",
1595
+ "Use as a quick triage signal when investigating a single key or before letting an agent rotate credentials; prefer `health_check` for a scope-wide decay+anomaly summary, and `agent_scan` for multi-project JSON reports with optional auto-rotation.",
1596
+ "Read-only; never mutates secrets or the audit log. Returns one line per finding formatted `[type] description`, or 'No anomalies detected' when the log looks clean."
1597
+ ].join(" "),
1408
1598
  {
1409
- key: z6.string().optional().describe("Check anomalies for a specific key")
1599
+ key: z6.string().optional().describe(
1600
+ "If provided, narrow the scan to this exact key. Omit to scan across every key in the audit log."
1601
+ )
1410
1602
  },
1411
1603
  async (params) => {
1412
1604
  const toolBlock = enforceToolPolicy("detect_anomalies");
@@ -1419,7 +1611,11 @@ function registerAuditTools(server2) {
1419
1611
  );
1420
1612
  server2.tool(
1421
1613
  "health_check",
1422
- "[health] Read-only scoped pass: decay/stale/expired counts, per-secret issue lines, plus audit-derived anomalies. No writes. Use check_project for .q-ring.json manifest compliance; use detect_anomalies for audit-pattern-only triage; use agent_scan for multi-project JSON or optional autoRotate credential replacement.",
1614
+ [
1615
+ "[health] Run a single read-only sweep over every secret in the requested scope and report counts of healthy/stale/expired secrets plus any current audit anomalies.",
1616
+ "Use as the default 'is everything OK?' command for an agent or operator; prefer `check_project` to validate manifest compliance specifically, `detect_anomalies` for audit-only triage, and `agent_scan` for multi-project JSON output or optional auto-rotation.",
1617
+ "Read-only \u2014 never writes. Returns a multi-line text summary: header counts (Total / Healthy / Stale / Expired / No decay / Anomalies), then per-secret `EXPIRED:` / `STALE:` issue lines, then per-anomaly `[type] description` lines."
1618
+ ].join(" "),
1423
1619
  {
1424
1620
  scope: scope4,
1425
1621
  projectPath: projectPath4,
@@ -1473,7 +1669,11 @@ function registerAuditTools(server2) {
1473
1669
  );
1474
1670
  server2.tool(
1475
1671
  "verify_audit_chain",
1476
- "[audit] Verify the tamper-evident hash chain of the audit log. Returns integrity status and the first break point if tampered.",
1672
+ [
1673
+ "[audit] Recompute the SHA-256 hash chain over the audit log and confirm no event has been mutated, deleted, or reordered.",
1674
+ "Use periodically as a tamper-evidence check, or whenever you suspect the audit log has been touched outside q-ring; the result is informational \u2014 this tool does not repair the chain if it is broken.",
1675
+ "Read-only. Returns JSON `{ ok, valid, brokenAt? }` where `valid` is `true` for an intact chain and `brokenAt` (when present) names the first event whose hash did not match."
1676
+ ].join(" "),
1477
1677
  {},
1478
1678
  async () => {
1479
1679
  const toolBlock = enforceToolPolicy("verify_audit_chain");
@@ -1484,11 +1684,21 @@ function registerAuditTools(server2) {
1484
1684
  );
1485
1685
  server2.tool(
1486
1686
  "export_audit",
1487
- "[audit] Export audit events in a portable format (jsonl, json, or csv) with optional time range filtering.",
1687
+ [
1688
+ "[audit] Export the audit log as a portable text artifact suitable for archiving or feeding into another SIEM/analyzer.",
1689
+ "Use for compliance exports, after-the-fact investigations, or to hand the trail to a non-MCP consumer; prefer `audit_log` for an in-conversation tail and `verify_audit_chain` to confirm integrity before exporting.",
1690
+ "Read-only. Returns the rendered text directly (no JSON wrapper). 'jsonl' is one event per line; 'json' is a single array; 'csv' is a header row plus events. Time filters are applied to the event timestamps before formatting."
1691
+ ].join(" "),
1488
1692
  {
1489
- since: z6.string().optional().describe("Start date (ISO 8601)"),
1490
- until: z6.string().optional().describe("End date (ISO 8601)"),
1491
- format: z6.enum(["jsonl", "json", "csv"]).optional().default("jsonl").describe("Output format")
1693
+ since: z6.string().optional().describe(
1694
+ "Inclusive lower bound on event timestamp, ISO 8601. Example: '2026-04-01T00:00:00Z'. Omit for no lower bound."
1695
+ ),
1696
+ until: z6.string().optional().describe(
1697
+ "Inclusive upper bound on event timestamp, ISO 8601. Omit for now/no upper bound."
1698
+ ),
1699
+ format: z6.enum(["jsonl", "json", "csv"]).optional().default("jsonl").describe(
1700
+ "Output format. 'jsonl' (default) is most stream-friendly; 'json' is a single array; 'csv' is spreadsheet-friendly."
1701
+ )
1492
1702
  },
1493
1703
  async (params) => {
1494
1704
  const toolBlock = enforceToolPolicy("export_audit");
@@ -1509,10 +1719,18 @@ var { teamId: teamId5, orgId: orgId5, scope: scope5, projectPath: projectPath5 }
1509
1719
  function registerValidationTools(server2) {
1510
1720
  server2.tool(
1511
1721
  "validate_secret",
1512
- "[validation] Test if a secret is actually valid with its target service (e.g., OpenAI, Stripe, GitHub). Uses provider auto-detection based on key prefixes, or accepts an explicit provider name. Never logs the secret value.",
1722
+ [
1723
+ "[validation] Test whether a stored secret is still accepted by its upstream service (OpenAI, Stripe, GitHub, AWS, generic HTTP, etc.) by making a minimal authenticated request.",
1724
+ "Use to confirm liveness before relying on a credential or as the verification step after `rotate_secret`; prefer `ci_validate_secrets` for a batch run across every key in scope.",
1725
+ "Side effects: makes one outbound network request per call (may incur tiny provider-side rate-limit cost). Records 'read' for the underlying secret value in the audit log; the value itself is never logged. Returns JSON `{ valid, provider, status?, message?, rateLimit?, ... }` (provider-specific shape)."
1726
+ ].join(" "),
1513
1727
  {
1514
- key: z7.string().describe("The secret key name"),
1515
- provider: z7.string().optional().describe("Force a specific provider (openai, stripe, github, aws, http)"),
1728
+ key: z7.string().describe(
1729
+ "The exact key whose value should be tested upstream. Example: 'OPENAI_API_KEY'."
1730
+ ),
1731
+ provider: z7.string().optional().describe(
1732
+ "Force a specific provider id. Built-ins include 'openai', 'stripe', 'github', 'aws', 'http'. Omit to auto-detect from the value's prefix or the secret's stored provider hint."
1733
+ ),
1516
1734
  scope: scope5,
1517
1735
  projectPath: projectPath5,
1518
1736
  teamId: teamId5,
@@ -1531,7 +1749,11 @@ function registerValidationTools(server2) {
1531
1749
  );
1532
1750
  server2.tool(
1533
1751
  "list_providers",
1534
- "[validation] List all available validation providers for secret liveness testing.",
1752
+ [
1753
+ "[validation] Enumerate the secret-validation providers q-ring knows how to call (OpenAI, Stripe, GitHub, \u2026) along with their auto-detect prefixes.",
1754
+ "Use to discover what `provider` string to pass to `validate_secret`/`rotate_secret`, or to check whether your custom provider is registered.",
1755
+ "Read-only. Returns JSON array of `{ name, description, prefixes }` objects. `prefixes` are the literal key-value prefixes (e.g. 'sk-' for OpenAI) used for auto-detection."
1756
+ ].join(" "),
1535
1757
  {},
1536
1758
  async () => {
1537
1759
  const toolBlock = enforceToolPolicy("list_providers");
@@ -1546,10 +1768,16 @@ function registerValidationTools(server2) {
1546
1768
  );
1547
1769
  server2.tool(
1548
1770
  "rotate_secret",
1549
- "[validation] Attempt issuer-native rotation of a secret via its detected or specified provider. Returns rotation result.",
1771
+ [
1772
+ "[validation] Ask the upstream provider to issue a fresh credential for this secret and store the new value back into the keyring.",
1773
+ "Use when a secret is expiring, leaked, or part of a scheduled rotation; prefer `generate_secret` for self-managed values you fully control, and `agent_scan --autoRotate` for sweep-style rotation across multiple expired keys.",
1774
+ "Mutates the keyring with the newly-issued value if rotation succeeds (one 'write' audit event), and makes outbound network requests against the provider's rotation API. Returns JSON `{ rotated, newValue?, message?, ... }`. If `rotated` is false, the existing value is left untouched."
1775
+ ].join(" "),
1550
1776
  {
1551
- key: z7.string().describe("The secret key to rotate"),
1552
- provider: z7.string().optional().describe("Force a specific provider"),
1777
+ key: z7.string().describe("Exact key to rotate. Must already exist in the keyring."),
1778
+ provider: z7.string().optional().describe(
1779
+ "Force a specific provider id (see `list_providers`). Omit to auto-detect from the current value or the secret's stored provider hint."
1780
+ ),
1553
1781
  scope: scope5,
1554
1782
  projectPath: projectPath5,
1555
1783
  teamId: teamId5,
@@ -1573,7 +1801,11 @@ function registerValidationTools(server2) {
1573
1801
  );
1574
1802
  server2.tool(
1575
1803
  "ci_validate_secrets",
1576
- "[validation] CI-oriented batch validation: validates all accessible secrets against their providers and returns a structured pass/fail report.",
1804
+ [
1805
+ "[validation] Validate every accessible secret in the requested scope against its detected provider in a single batch and return a structured pass/fail report.",
1806
+ "Use as a CI gate ('do all our credentials still work before deploy?') or as a pre-rotation health pass; prefer `validate_secret` for a single key.",
1807
+ "Side effects: one outbound request per validatable secret (cost scales with N). Reads each secret value (records 'read' audit events). Returns JSON `{ total, valid, invalid, results: [...] }` listing per-key status, provider, and error messages where applicable. Returns 'No secrets to validate' if nothing in scope has a provider mapping."
1808
+ ].join(" "),
1577
1809
  {
1578
1810
  scope: scope5,
1579
1811
  projectPath: projectPath5,
@@ -1613,19 +1845,45 @@ import { z as z8 } from "zod";
1613
1845
  function registerHookTools(server2) {
1614
1846
  server2.tool(
1615
1847
  "register_hook",
1616
- "[hooks] Register a webhook/callback that fires when a secret is updated, deleted, or rotated. Supports shell commands, HTTP webhooks, and process signals.",
1848
+ [
1849
+ "[hooks] Register a side-effect (shell command, HTTP webhook, or process signal) that fires automatically when a matching secret is written, deleted, or rotated.",
1850
+ "Use to keep external systems in sync (restart a service after rotation, post to Slack on delete, kick a build); prefer `agent_remember` for storing facts an agent should recall later, and `register_hook` is not the right tool for time-based scheduled rotation (use `agent_scan` for that).",
1851
+ "Mutates the hook registry on disk. At least one match criterion (`key`, `keyPattern`, or `tag`) is required \u2014 calls without any return an error. Returns JSON of the registered hook entry including its assigned `id` (use that `id` with `remove_hook`)."
1852
+ ].join(" "),
1617
1853
  {
1618
- type: z8.enum(["shell", "http", "signal"]).describe("Hook type"),
1619
- key: z8.string().optional().describe("Trigger on exact key match"),
1620
- keyPattern: z8.string().optional().describe("Trigger on key glob pattern (e.g. DB_*)"),
1621
- tag: z8.string().optional().describe("Trigger on secrets with this tag"),
1622
- scope: z8.enum(["global", "project"]).optional().describe("Trigger only for this scope"),
1623
- actions: z8.array(z8.enum(["write", "delete", "rotate"])).optional().default(["write", "delete", "rotate"]).describe("Which actions trigger this hook"),
1624
- command: z8.string().optional().describe("Shell command to execute (for shell type)"),
1625
- url: z8.string().optional().describe("URL to POST to (for http type)"),
1626
- signalTarget: z8.string().optional().describe("Process name or PID (for signal type)"),
1627
- signalName: z8.string().optional().default("SIGHUP").describe("Signal to send (for signal type)"),
1628
- description: z8.string().optional().describe("Human-readable description")
1854
+ type: z8.enum(["shell", "http", "signal"]).describe(
1855
+ "Hook delivery mechanism. 'shell' runs a local command, 'http' POSTs JSON to a URL, 'signal' sends an OS signal to a named process."
1856
+ ),
1857
+ key: z8.string().optional().describe(
1858
+ "Trigger only on this exact key name. Pick at most one of `key` / `keyPattern` / `tag` (or combine for stricter matching)."
1859
+ ),
1860
+ keyPattern: z8.string().optional().describe(
1861
+ "Trigger on any key matching this glob pattern. Examples: 'DB_*', 'STRIPE_*'."
1862
+ ),
1863
+ tag: z8.string().optional().describe(
1864
+ "Trigger on any secret carrying this exact tag. Combinable with key/keyPattern as an AND filter."
1865
+ ),
1866
+ scope: z8.enum(["global", "project"]).optional().describe(
1867
+ "Restrict the hook to secrets in this scope. Omit to fire across both global and project secrets."
1868
+ ),
1869
+ actions: z8.array(z8.enum(["write", "delete", "rotate"])).optional().default(["write", "delete", "rotate"]).describe(
1870
+ "Which lifecycle actions trigger this hook. Defaults to all three."
1871
+ ),
1872
+ command: z8.string().optional().describe(
1873
+ "Required when type='shell'. The literal shell command to run; q-ring exposes the matching key as $QRING_HOOK_KEY and action as $QRING_HOOK_ACTION."
1874
+ ),
1875
+ url: z8.string().optional().describe(
1876
+ "Required when type='http'. Full URL to POST a JSON body `{ id, key, scope, action, timestamp }` to (the value itself is never sent)."
1877
+ ),
1878
+ signalTarget: z8.string().optional().describe(
1879
+ "Required when type='signal'. Either a numeric PID or a process name resolvable via `ps`."
1880
+ ),
1881
+ signalName: z8.string().optional().default("SIGHUP").describe(
1882
+ "Signal name to send (e.g. 'SIGHUP', 'SIGUSR1'). Defaults to SIGHUP, which most daemons treat as 'reload config'."
1883
+ ),
1884
+ description: z8.string().optional().describe(
1885
+ "Free-text human-readable description, surfaced by `list_hooks` and the dashboard."
1886
+ )
1629
1887
  },
1630
1888
  async (params) => {
1631
1889
  const toolBlock = enforceToolPolicy("register_hook");
@@ -1656,7 +1914,11 @@ function registerHookTools(server2) {
1656
1914
  );
1657
1915
  server2.tool(
1658
1916
  "list_hooks",
1659
- "[hooks] List all registered secret change hooks with their match criteria, type, and status.",
1917
+ [
1918
+ "[hooks] Enumerate every registered lifecycle hook with its match criteria, delivery type, enabled flag, and description.",
1919
+ "Use to find a hook's `id` before calling `remove_hook`, audit what side effects are wired up, or diagnose why a hook did not fire.",
1920
+ "Read-only. Returns pretty-printed JSON array of hook entries, or 'No hooks registered' when the registry is empty."
1921
+ ].join(" "),
1660
1922
  {},
1661
1923
  async () => {
1662
1924
  const toolBlock = enforceToolPolicy("list_hooks");
@@ -1668,9 +1930,15 @@ function registerHookTools(server2) {
1668
1930
  );
1669
1931
  server2.tool(
1670
1932
  "remove_hook",
1671
- "[hooks] Remove a lifecycle hook entry by id from the hook registry only (stops callbacks; does not touch secret values). Call list_hooks first for ids. Contrast delete_secret (credential removal) or tunnel_destroy (ephemeral tunnel). Returns success or not-found; subject to tool policy.",
1933
+ [
1934
+ "[hooks] Detach a single lifecycle hook by its registry id so it stops firing.",
1935
+ "Use to retire a specific webhook/command without touching any secrets; prefer `delete_secret` to remove a credential and `tunnel_destroy` for ephemeral tunnels.",
1936
+ "Mutates the hook registry only \u2014 does not touch secret values, audit log, or env states. Idempotent in spirit: removing an already-absent id returns a not-found error rather than partial work. Returns 'Removed hook ID' on success."
1937
+ ].join(" "),
1672
1938
  {
1673
- id: z8.string().describe("Hook ID to remove")
1939
+ id: z8.string().describe(
1940
+ "Hook id returned by `register_hook` or visible in `list_hooks` (opaque string)."
1941
+ )
1674
1942
  },
1675
1943
  async (params) => {
1676
1944
  const toolBlock = enforceToolPolicy("remove_hook");
@@ -1920,7 +2188,8 @@ async function execCommand(opts2) {
1920
2188
  "dig",
1921
2189
  "nslookup"
1922
2190
  ]);
1923
- if (profile.allowNetwork === false && networkTools.has(opts2.command)) {
2191
+ const commandBase = opts2.command.split(/[\\/]/).pop() ?? opts2.command;
2192
+ if (profile.allowNetwork === false && networkTools.has(commandBase)) {
1924
2193
  const msg = `[QRING] Execution blocked: network access is disabled for profile "${profile.name}", command "${opts2.command}" is considered network-related`;
1925
2194
  if (opts2.captureOutput) {
1926
2195
  return resolve({ code: 126, stdout: "", stderr: msg });
@@ -2203,13 +2472,27 @@ var { teamId: teamId6, orgId: orgId6, scope: scope6, projectPath: projectPath6 }
2203
2472
  function registerToolingTools(server2) {
2204
2473
  server2.tool(
2205
2474
  "exec_with_secrets",
2206
- "[exec] Run a shell command securely. Project secrets are injected into the environment, and any secret values in the output are automatically redacted to prevent leaking into transcripts.",
2475
+ [
2476
+ "[exec] Run a child shell command with project secrets injected as environment variables and any leaked secret values redacted from captured stdout/stderr before they return to the agent.",
2477
+ "Use to let an agent run a script that needs credentials (`npm run db:migrate`, `terraform plan`, `vercel deploy`) without ever putting plaintext values in the chat; prefer `env_generate` if you need to write a `.env` file to disk and `validate_secret` for upstream liveness checks.",
2478
+ "Spawns a real child process \u2014 has whatever side effects the command itself causes (writes, network, exec). Subject to BOTH tool policy and exec policy (allowlist/denylist). Returns a text body with `Exit code: N` then `STDOUT:` and `STDERR:` blocks; both streams are scrubbed against the secret values that were injected."
2479
+ ].join(" "),
2207
2480
  {
2208
- command: z9.string().describe("Command to run"),
2209
- args: z9.array(z9.string()).optional().describe("Command arguments"),
2210
- keys: z9.array(z9.string()).optional().describe("Only inject these specific keys"),
2211
- tags: z9.array(z9.string()).optional().describe("Only inject secrets with these tags"),
2212
- profile: z9.enum(["unrestricted", "restricted", "ci"]).optional().default("restricted").describe("Exec profile: unrestricted, restricted, or ci"),
2481
+ command: z9.string().describe(
2482
+ "Executable name or full command to run. Example: 'pnpm', 'node', '/usr/bin/env'. Must be allowed by exec policy."
2483
+ ),
2484
+ args: z9.array(z9.string()).optional().describe(
2485
+ "Positional arguments passed to `command`. Example: ['run', 'db:migrate']. Each element is passed verbatim with no extra shell parsing."
2486
+ ),
2487
+ keys: z9.array(z9.string()).optional().describe(
2488
+ "Whitelist of exact key names to inject. Omit to inject every secret in scope (subject to `tags`)."
2489
+ ),
2490
+ tags: z9.array(z9.string()).optional().describe(
2491
+ "Inject only secrets carrying at least one of these tags. Combinable with `keys` as an AND filter."
2492
+ ),
2493
+ profile: z9.enum(["unrestricted", "restricted", "ci"]).optional().default("restricted").describe(
2494
+ "Exec sandbox profile. 'restricted' (default) limits PATH and inheritable env vars; 'ci' is restricted plus CI-friendly defaults (no TTY); 'unrestricted' inherits the full server environment \u2014 only pick this when you understand the leak risk."
2495
+ ),
2213
2496
  scope: scope6,
2214
2497
  projectPath: projectPath6,
2215
2498
  teamId: teamId6,
@@ -2251,9 +2534,15 @@ ${result.stderr}`);
2251
2534
  );
2252
2535
  server2.tool(
2253
2536
  "scan_codebase_for_secrets",
2254
- "[scan] Scan a directory for hardcoded secrets using regex heuristics and Shannon entropy analysis. Returns file paths, line numbers, and the matched key/value to help migrate legacy codebases into q-ring.",
2537
+ [
2538
+ "[scan] Walk a directory tree and flag plausible hardcoded secrets using regex heuristics plus Shannon-entropy scoring on string literals.",
2539
+ "Use as a one-shot 'is anything leaking in this repo?' audit before commit/release; prefer `lint_files` when you already know the specific files to check (and want optional auto-fix).",
2540
+ "Read-only \u2014 never modifies source files. Honors `.gitignore`. Returns JSON array of `{ file, line, key, value, kind }` findings, or 'No hardcoded secrets found in the specified directory.' when clean. False positives are possible \u2014 review before treating as ground truth."
2541
+ ].join(" "),
2255
2542
  {
2256
- dirPath: z9.string().describe("Absolute or relative path to the directory to scan")
2543
+ dirPath: z9.string().describe(
2544
+ "Directory to scan, absolute or relative to the server cwd. The scan recurses into subdirectories."
2545
+ )
2257
2546
  },
2258
2547
  async (params) => {
2259
2548
  const toolBlock = enforceToolPolicy("scan_codebase_for_secrets");
@@ -2274,10 +2563,18 @@ ${result.stderr}`);
2274
2563
  );
2275
2564
  server2.tool(
2276
2565
  "lint_files",
2277
- "[scan] Scan specific files for hardcoded secrets. Optionally auto-fix by replacing them with process.env references and storing the values in q-ring.",
2566
+ [
2567
+ "[scan] Inspect a specific list of files for hardcoded secrets and, when `fix` is true, replace each finding with `process.env.KEY` while storing the extracted value into the keyring.",
2568
+ "Use to migrate a known set of files (e.g. just-changed files in a pre-commit hook) into q-ring; prefer `scan_codebase_for_secrets` for a whole-tree audit and `import_dotenv` to ingest an existing .env.",
2569
+ "With `fix: false` this is read-only. With `fix: true` this MUTATES the listed source files in place (review with git diff!) and writes one new secret per finding to the keyring. Returns a JSON array of `{ file, line, key, value, kind }` findings, or 'No hardcoded secrets found in the specified files.'."
2570
+ ].join(" "),
2278
2571
  {
2279
- files: z9.array(z9.string()).describe("File paths to lint"),
2280
- fix: z9.boolean().optional().default(false).describe("Auto-replace and store secrets"),
2572
+ files: z9.array(z9.string()).describe(
2573
+ "Absolute or relative paths to lint. Non-existent paths surface as scan errors."
2574
+ ),
2575
+ fix: z9.boolean().optional().default(false).describe(
2576
+ "If true, rewrite the source files to read `process.env.KEY` and store the extracted value in the keyring. If false (default), only report findings."
2577
+ ),
2281
2578
  scope: scope6,
2282
2579
  projectPath: projectPath6,
2283
2580
  teamId: teamId6,
@@ -2306,7 +2603,11 @@ ${result.stderr}`);
2306
2603
  );
2307
2604
  server2.tool(
2308
2605
  "analyze_secrets",
2309
- "[agent] Analyze secret usage patterns and provide optimization suggestions including most accessed, stale, unused, and rotation recommendations.",
2606
+ [
2607
+ "[agent] Cross-reference the secrets in scope with recent audit events to produce a usage profile and rotation/retirement suggestions.",
2608
+ "Use as a quarterly hygiene check or as input to a planner that decides what to rotate or delete; prefer `health_check` for decay-only triage and `audit_log` to inspect access timelines for one key.",
2609
+ "Read-only; uses the most recent ~500 audit events. Returns JSON `{ total, expired, stale, neverAccessed: [...], noRotationFormat: [...], mostAccessed: [{ key, reads }] }`. `neverAccessed` and `noRotationFormat` are good candidates for cleanup or for adding rotation hints."
2610
+ ].join(" "),
2310
2611
  {
2311
2612
  scope: scope6,
2312
2613
  projectPath: projectPath6,
@@ -2339,32 +2640,46 @@ ${result.stderr}`);
2339
2640
  let dashboardInstance = null;
2340
2641
  server2.tool(
2341
2642
  "status_dashboard",
2342
- "[dashboard] Launch the quantum status dashboard \u2014 a local SSE-driven web page showing live KPIs (secrets, env, protected, approvals, hooks, 24h reads, anomalies), health summary, environment, .q-ring.json manifest gaps, governance policy summary, sortable searchable secrets table, decay/superposition/entanglement/tunnel cards, active approvals & hooks, agent memory, anomaly alerts, and a filterable 24h audit feed. Returns the URL to open in a browser. Never exposes secret values.",
2643
+ [
2644
+ "[dashboard] Start a local web dashboard (`http://127.0.0.1:PORT`) that streams live KPIs, secret tables, manifest gaps, hooks, audit events, and anomalies via Server-Sent Events.",
2645
+ "Use when an operator (or an agent on behalf of one) wants a richer visual surface than chat output; prefer `health_check` / `analyze_secrets` for one-shot text summaries inside the conversation.",
2646
+ "Side effect: binds an HTTP server on the requested port (one process-wide instance \u2014 re-running returns the existing URL instead of starting a second server). Never exposes secret values. Returns the URL string to open in a browser."
2647
+ ].join(" "),
2343
2648
  {
2344
- port: z9.number().optional().default(9876).describe("Port to serve on")
2649
+ port: z9.number().optional().default(9876).describe(
2650
+ "TCP port to listen on (default 9876). Pick another port if 9876 is already in use; the call fails if binding errors."
2651
+ )
2345
2652
  },
2346
2653
  async (params) => {
2347
2654
  const toolBlock = enforceToolPolicy("status_dashboard");
2348
2655
  if (toolBlock) return toolBlock;
2349
2656
  if (dashboardInstance) {
2350
2657
  return text(
2351
- `Dashboard already running at http://127.0.0.1:${dashboardInstance.port}`
2658
+ `Dashboard already running at ${dashboardInstance.url}`
2352
2659
  );
2353
2660
  }
2354
- const { startDashboardServer } = await import("./dashboard-FSCJDLJX.js");
2661
+ const { startDashboardServer } = await import("./dashboard-HHJY73JX.js");
2355
2662
  dashboardInstance = startDashboardServer({ port: params.port });
2356
2663
  return text(
2357
- `Dashboard started at http://127.0.0.1:${dashboardInstance.port}
2358
- Open this URL in a browser to see live quantum status.`
2664
+ `Dashboard started at ${dashboardInstance.url}
2665
+ Open this URL in a browser to see live quantum status. The token is required for access.`
2359
2666
  );
2360
2667
  }
2361
2668
  );
2362
2669
  server2.tool(
2363
2670
  "agent_scan",
2364
- "[agent] Multi-project health pass: decay, staleness, audit anomalies, manifest gaps; returns JSON. Prefer health_check for a read-only scoped decay/anomaly text summary (no writes). Prefer detect_anomalies for audit-pattern spikes on one key. With autoRotate=true, overwrites expired secret values in the keyring (credential change\u2014not undoable); leave false unless intentional rotation. Same policy gates as other MCP tools; no separate external auth.",
2671
+ [
2672
+ "[agent] Run a multi-project health pass that gathers decay status, audit anomalies, and `.q-ring.json` manifest gaps across one or more project paths and (optionally) auto-rotates expired secrets with freshly generated values.",
2673
+ "Use as the canonical 'agent maintenance loop' across a portfolio of repos; prefer `health_check` for a single read-only scope, `detect_anomalies` for audit-only triage, and `check_project` for a single-project manifest check.",
2674
+ "With `autoRotate=false` (default) this is read-only. With `autoRotate=true` it OVERWRITES expired secret values in the keyring with generated replacements \u2014 credential changes that may break upstream integrations until they are propagated. Subject to tool policy. Returns a JSON report of per-project findings and any rotations performed."
2675
+ ].join(" "),
2365
2676
  {
2366
- autoRotate: z9.boolean().optional().default(false).describe("Auto-rotate expired secrets with generated values"),
2367
- projectPaths: z9.array(z9.string()).optional().describe("Project paths to monitor")
2677
+ autoRotate: z9.boolean().optional().default(false).describe(
2678
+ "If true, replace expired secrets with newly generated values (using each secret's `rotationFormat`/`rotationPrefix`). Only enable when intentional rotation is desired \u2014 this is destructive on the upstream side."
2679
+ ),
2680
+ projectPaths: z9.array(z9.string()).optional().describe(
2681
+ "List of absolute project roots to scan. Defaults to `[server.cwd]` when omitted."
2682
+ )
2368
2683
  },
2369
2684
  async (params) => {
2370
2685
  const toolBlock = enforceToolPolicy("agent_scan");
@@ -2383,10 +2698,18 @@ import { z as z10 } from "zod";
2383
2698
  function registerAgentTools(server2) {
2384
2699
  server2.tool(
2385
2700
  "agent_remember",
2386
- "[agent] Store a key-value pair in encrypted agent memory that persists across sessions. Use this to remember decisions, rotation history, or project-specific context.",
2701
+ [
2702
+ "[agent] Persist a non-secret key/value note in encrypted, on-disk agent memory that survives across MCP sessions.",
2703
+ "Use to record stable agent context \u2014 last rotation date for a key, the user's deployment preferences, decisions taken in earlier sessions; do NOT use this to store secrets (use `set_secret` instead) and prefer chat scratchpad for purely transient state.",
2704
+ `Mutates the encrypted memory store. Idempotent: rewriting the same key with a new value simply overwrites. Returns 'Remembered "KEY"' on success.`
2705
+ ].join(" "),
2387
2706
  {
2388
- key: z10.string().describe("Memory key"),
2389
- value: z10.string().describe("Value to store")
2707
+ key: z10.string().describe(
2708
+ "Memory key (free-form string). Convention: lowercase dotted namespaces, e.g. 'project.lastDeploy'."
2709
+ ),
2710
+ value: z10.string().describe(
2711
+ "Plain-string value to store. JSON-stringify structured data on the caller side if needed."
2712
+ )
2390
2713
  },
2391
2714
  async (params) => {
2392
2715
  const toolBlock = enforceToolPolicy("agent_remember");
@@ -2397,9 +2720,15 @@ function registerAgentTools(server2) {
2397
2720
  );
2398
2721
  server2.tool(
2399
2722
  "agent_recall",
2400
- "[agent] Retrieve a value from agent memory, or list all stored keys if no key is provided.",
2723
+ [
2724
+ "[agent] Read a value from encrypted agent memory, or list every stored key when no specific key is supplied.",
2725
+ "Use at the start of an agent loop to rehydrate prior context, or to look up a single remembered fact; prefer `get_project_context` for a redacted overview of secrets and `get_secret` for actual credential values.",
2726
+ "Read-only. With a `key` argument: returns JSON `{ ok, data: { key, value } }` or a not-found error. Without `key`: returns a JSON listing of every stored key (no values), or 'Agent memory is empty'."
2727
+ ].join(" "),
2401
2728
  {
2402
- key: z10.string().optional().describe("Memory key to recall (omit to list all)")
2729
+ key: z10.string().optional().describe(
2730
+ "Memory key to read. Omit to list every stored key (without values)."
2731
+ )
2403
2732
  },
2404
2733
  async (params) => {
2405
2734
  const toolBlock = enforceToolPolicy("agent_recall");
@@ -2419,9 +2748,13 @@ function registerAgentTools(server2) {
2419
2748
  );
2420
2749
  server2.tool(
2421
2750
  "agent_forget",
2422
- "[agent] Delete a key from agent memory.",
2751
+ [
2752
+ "[agent] Permanently delete a single key from encrypted agent memory.",
2753
+ "Use to retract obsolete or misremembered context; prefer overwriting via `agent_remember` when you just want to update the value, and use `delete_secret` for actual credentials (which never live in agent memory).",
2754
+ `Destructive: there is no recycle bin. Returns 'Forgot "KEY"' on success or a not-found error if the key was already absent.`
2755
+ ].join(" "),
2423
2756
  {
2424
- key: z10.string().describe("Memory key to forget")
2757
+ key: z10.string().describe("Memory key to delete.")
2425
2758
  },
2426
2759
  async (params) => {
2427
2760
  const toolBlock = enforceToolPolicy("agent_forget");
@@ -2441,12 +2774,24 @@ var { projectPath: projectPath7 } = commonSchemas;
2441
2774
  function registerPolicyTools(server2) {
2442
2775
  server2.tool(
2443
2776
  "check_policy",
2444
- "[policy] Check if an action is allowed by the project's governance policy. Returns the policy decision and source.",
2777
+ [
2778
+ "[policy] Ask whether a single intended action would be allowed by the project's `.q-ring.json` policy without actually performing it.",
2779
+ "Use as a dry-run before calling a potentially-blocked tool, attempting to read a sensitive key, or invoking `exec_with_secrets` with a non-trivial command; prefer `get_policy_summary` for a one-shot overview of the entire policy.",
2780
+ "Read-only. Returns JSON `{ allowed, reason?, policySource }` describing the decision. Returns an error 'Missing required parameter for the selected action type' if the matching argument for the chosen `action` is not supplied."
2781
+ ].join(" "),
2445
2782
  {
2446
- action: z11.enum(["tool", "key_read", "exec"]).describe("Type of policy check"),
2447
- toolName: z11.string().optional().describe("Tool name to check (for action=tool)"),
2448
- key: z11.string().optional().describe("Secret key to check (for action=key_read)"),
2449
- command: z11.string().optional().describe("Command to check (for action=exec)"),
2783
+ action: z11.enum(["tool", "key_read", "exec"]).describe(
2784
+ "Which policy surface to query. 'tool' = MCP tool gate (needs `toolName`); 'key_read' = secret read gate (needs `key`); 'exec' = exec_with_secrets command gate (needs `command`)."
2785
+ ),
2786
+ toolName: z11.string().optional().describe(
2787
+ "Tool id to evaluate, e.g. 'rotate_secret'. Required when `action` is 'tool'."
2788
+ ),
2789
+ key: z11.string().optional().describe(
2790
+ "Secret key name to evaluate. Required when `action` is 'key_read'."
2791
+ ),
2792
+ command: z11.string().optional().describe(
2793
+ "Command to evaluate against the exec allowlist/denylist. Required when `action` is 'exec'."
2794
+ ),
2450
2795
  projectPath: projectPath7
2451
2796
  },
2452
2797
  async (params) => {
@@ -2470,7 +2815,11 @@ function registerPolicyTools(server2) {
2470
2815
  );
2471
2816
  server2.tool(
2472
2817
  "get_policy_summary",
2473
- "[policy] Get a summary of the project's governance policy configuration.",
2818
+ [
2819
+ "[policy] Return a high-level summary of the project's `.q-ring.json` governance policy \u2014 counts of allow/deny rules for tools, key reads, exec commands, plus approval and rotation requirements.",
2820
+ "Use to orient an agent (or the user) on what guardrails are active before attempting policy-restricted actions; prefer `check_policy` for a precise per-action verdict.",
2821
+ "Read-only. Returns pretty-printed JSON; missing policy file returns an empty/default summary rather than an error so callers can branch on the counts."
2822
+ ].join(" "),
2474
2823
  {
2475
2824
  projectPath: projectPath7
2476
2825
  },
@@ -2502,6 +2851,7 @@ function registerMcpTools(server2) {
2502
2851
 
2503
2852
  // src/mcp/server.ts
2504
2853
  function createMcpServer() {
2854
+ setPolicyRoot(process.cwd());
2505
2855
  const server2 = new McpServer({
2506
2856
  name: "q-ring",
2507
2857
  version: PACKAGE_VERSION