@mrciphersmith/keryx 0.3.51 → 0.3.52

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/cli.js CHANGED
@@ -20094,7 +20094,7 @@ var package_default;
20094
20094
  var init_package = __esm(() => {
20095
20095
  package_default = {
20096
20096
  name: "@mrciphersmith/keryx",
20097
- version: "0.3.51",
20097
+ version: "0.3.52",
20098
20098
  description: "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
20099
20099
  private: false,
20100
20100
  publishConfig: {
@@ -109149,7 +109149,7 @@ var init_serve_credential = __esm(() => {
109149
109149
  });
109150
109150
 
109151
109151
  // src/remote/shell-token.ts
109152
- import { createHash as createHash62, randomBytes as randomBytes10 } from "crypto";
109152
+ import { createHash as createHash62, createHmac as createHmac5, randomBytes as randomBytes10 } from "crypto";
109153
109153
  import { statSync as statSync16 } from "fs";
109154
109154
  import path246 from "path";
109155
109155
  function shellTokenPath(dir) {
@@ -109191,16 +109191,59 @@ function mintShellToken(dir) {
109191
109191
  function readShellToken(dir) {
109192
109192
  return readValidated(shellTokenPath(dir));
109193
109193
  }
109194
+ function mac(token, parts) {
109195
+ const hmac = createHmac5("sha256", token);
109196
+ for (const part of parts) {
109197
+ const bytes = Buffer.from(part, "utf8");
109198
+ hmac.update(`${bytes.byteLength}:`);
109199
+ hmac.update(bytes);
109200
+ }
109201
+ return hmac.digest("base64url");
109202
+ }
109203
+ function macEqual(a, b) {
109204
+ return constantTimeEqual(createHash62("sha256").update(a, "utf8").digest(), createHash62("sha256").update(b, "utf8").digest());
109205
+ }
109206
+ function shellRequestCredential(token) {
109207
+ const nonce = randomBytes10(18).toString("base64url");
109208
+ return { nonce, bearer: `${DERIVED_BEARER_PREFIX}${nonce}.${mac(token, [AUTH_DOMAIN, nonce])}` };
109209
+ }
109210
+ function derivedBearerNonce(presented) {
109211
+ if (!presented.startsWith(DERIVED_BEARER_PREFIX)) {
109212
+ return;
109213
+ }
109214
+ const [nonce, presentedMac, ...rest] = presented.slice(DERIVED_BEARER_PREFIX.length).split(".");
109215
+ if (rest.length > 0 || nonce === undefined || presentedMac === undefined || !NONCE_SHAPE.test(nonce) || !MAC_SHAPE.test(presentedMac)) {
109216
+ return;
109217
+ }
109218
+ return nonce;
109219
+ }
109220
+ function serveResponseProof(token, nonce, route, status, body) {
109221
+ return mac(token, [PROOF_DOMAIN, nonce, route, String(status), body]);
109222
+ }
109223
+ function verifyServeResponseProof(token, nonce, route, status, body, presented) {
109224
+ if (presented === null || !MAC_SHAPE.test(presented)) {
109225
+ return false;
109226
+ }
109227
+ return macEqual(serveResponseProof(token, nonce, route, status, body), presented);
109228
+ }
109194
109229
  function createShellTokenVerifier(token) {
109195
109230
  const expected = createHash62("sha256").update(token, "utf8").digest();
109196
- return (presented) => constantTimeEqual(createHash62("sha256").update(presented, "utf8").digest(), expected);
109231
+ return (presented) => {
109232
+ const nonce = derivedBearerNonce(presented);
109233
+ if (nonce !== undefined) {
109234
+ return macEqual(presented, `${DERIVED_BEARER_PREFIX}${nonce}.${mac(token, [AUTH_DOMAIN, nonce])}`);
109235
+ }
109236
+ return constantTimeEqual(createHash62("sha256").update(presented, "utf8").digest(), expected);
109237
+ };
109197
109238
  }
109198
- var SHELL_TOKEN_FILE = "shell-token", TOKEN_SHAPE;
109239
+ var SHELL_TOKEN_FILE = "shell-token", TOKEN_SHAPE, SERVE_PROOF_HEADER = "x-keryx-serve-proof", DERIVED_BEARER_PREFIX = "ksp1.", AUTH_DOMAIN = "keryx-shell-auth-v1", PROOF_DOMAIN = "keryx-serve-proof-v1", NONCE_SHAPE, MAC_SHAPE;
109199
109240
  var init_shell_token = __esm(() => {
109200
109241
  init_config_dir();
109201
109242
  init_serve_credential();
109202
109243
  init_paths4();
109203
109244
  TOKEN_SHAPE = /^[A-Za-z0-9_-]{32,128}$/;
109245
+ NONCE_SHAPE = /^[A-Za-z0-9_-]{22,64}$/;
109246
+ MAC_SHAPE = /^[A-Za-z0-9_-]{43}$/;
109204
109247
  });
109205
109248
 
109206
109249
  // src/remote/client.ts
@@ -109376,6 +109419,7 @@ class RemoteClient {
109376
109419
  });
109377
109420
  }
109378
109421
  target(route, query = "") {
109422
+ const token = readShellToken(this.options.dir);
109379
109423
  const endpoint = readEndpoint(this.options.dir);
109380
109424
  if (!endpoint.ok) {
109381
109425
  throw new TransientClientError(endpoint.reason);
@@ -109386,7 +109430,6 @@ class RemoteClient {
109386
109430
  if (!this.isAlive(endpoint.value.pid)) {
109387
109431
  throw new TransientClientError(`the serve named in the endpoint file (pid ${endpoint.value.pid}) is not running as this user; refusing to send the shell token to whatever listens on that port`);
109388
109432
  }
109389
- const token = readShellToken(this.options.dir);
109390
109433
  if (!token.ok) {
109391
109434
  throw new TransientClientError(token.reason);
109392
109435
  }
@@ -109394,12 +109437,19 @@ class RemoteClient {
109394
109437
  }
109395
109438
  async post(route, body) {
109396
109439
  const { url, token } = this.target(route);
109397
- return this.fetchImpl(url, {
109440
+ const { nonce, bearer } = shellRequestCredential(token);
109441
+ const response = await this.fetchImpl(url, {
109398
109442
  method: "POST",
109399
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
109443
+ redirect: "manual",
109444
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
109400
109445
  body: JSON.stringify(body),
109401
109446
  signal: AbortSignal.any([AbortSignal.timeout(this.requestTimeoutMs), this.lifetime.signal])
109402
109447
  });
109448
+ const text = await response.text();
109449
+ if (!verifyServeResponseProof(token, nonce, route, response.status, text, response.headers.get(SERVE_PROOF_HEADER))) {
109450
+ throw new TransientClientError(UNVERIFIED_SERVE_MESSAGE);
109451
+ }
109452
+ return new Response(text, { status: response.status, headers: response.headers });
109403
109453
  }
109404
109454
  async heartbeat() {
109405
109455
  if (this.stopped || this.name === undefined) {
@@ -109461,11 +109511,18 @@ class RemoteClient {
109461
109511
  const abort = new AbortController;
109462
109512
  this.streamAbort = abort;
109463
109513
  const { url, token } = this.target("stream", `?sessionId=${encodeURIComponent(this.options.sessionId)}`);
109464
- const headers = { authorization: `Bearer ${token}`, accept: "text/event-stream" };
109514
+ const { nonce, bearer } = shellRequestCredential(token);
109515
+ const headers = { authorization: `Bearer ${bearer}`, accept: "text/event-stream" };
109465
109516
  if (this.lastCompleted > 0) {
109466
109517
  headers["last-event-id"] = String(this.lastCompleted);
109467
109518
  }
109468
- const response = await this.fetchImpl(url, { method: "GET", headers, signal: AbortSignal.any([abort.signal, this.lifetime.signal]) });
109519
+ const response = await this.fetchImpl(url, { method: "GET", redirect: "manual", headers, signal: AbortSignal.any([abort.signal, this.lifetime.signal]) });
109520
+ if (!verifyServeResponseProof(token, nonce, "stream", response.status, "", response.headers.get(SERVE_PROOF_HEADER))) {
109521
+ await response.body?.cancel().catch(() => {
109522
+ return;
109523
+ });
109524
+ throw new TransientClientError(UNVERIFIED_SERVE_MESSAGE);
109525
+ }
109469
109526
  if (!response.ok || response.body === null) {
109470
109527
  await response.text().catch(() => "");
109471
109528
  if (response.status === 404) {
@@ -109619,7 +109676,7 @@ class RemoteClient {
109619
109676
  this.earlyDecisions.clear();
109620
109677
  }
109621
109678
  }
109622
- var FatalClientError, TransientClientError, DEFAULT_BACKOFF_INITIAL_MS = 250, DEFAULT_BACKOFF_MAX_MS = 15000, DEFAULT_REQUEST_TIMEOUT_MS = 1e4, MAX_EARLY_DECISIONS = 32, MAX_COMPLETED_IDS = 256;
109679
+ var UNVERIFIED_SERVE_MESSAGE = "the program answering on keryx serve's port did not prove it is the keryx serve this shell trusts; its answer was ignored. Restart `keryx serve` (an older serve cannot prove itself; update keryx on both sides)", FatalClientError, TransientClientError, DEFAULT_BACKOFF_INITIAL_MS = 250, DEFAULT_BACKOFF_MAX_MS = 15000, DEFAULT_REQUEST_TIMEOUT_MS = 1e4, MAX_EARLY_DECISIONS = 32, MAX_COMPLETED_IDS = 256;
109623
109680
  var init_client3 = __esm(() => {
109624
109681
  init_serve_config();
109625
109682
  init_lease();
@@ -110205,6 +110262,7 @@ class ChannelsClient {
110205
110262
  return { ok: true, value: { deleted, remaining, topicsDeleted: hubWasRunning && remaining === 0, erased, message: `${topics} ${gone}` } };
110206
110263
  }
110207
110264
  target(route) {
110265
+ const token = readShellToken(this.options.dir);
110208
110266
  const endpoint = readEndpoint(this.options.dir);
110209
110267
  if (!endpoint.ok) {
110210
110268
  throw new ServeDown("serve-down", `keryx serve is not running (${endpoint.reason})`);
@@ -110215,28 +110273,23 @@ class ChannelsClient {
110215
110273
  if (!this.isAlive(endpoint.value.pid)) {
110216
110274
  throw new ServeDown("serve-down", `keryx serve is not running (pid ${endpoint.value.pid} is gone); start it with \`keryx serve\``);
110217
110275
  }
110218
- const token = readShellToken(this.options.dir);
110219
110276
  if (!token.ok) {
110220
110277
  throw new ServeDown("serve-down", `keryx serve is not accepting the shell yet (${token.reason})`);
110221
110278
  }
110222
110279
  return { url: `http://${authority(endpoint.value.address, endpoint.value.port)}${channelsRoutePath(route)}`, token: token.value };
110223
110280
  }
110224
- async post(route) {
110281
+ async send(route) {
110225
110282
  const { url, token } = this.target(route);
110226
- return this.fetchImpl(url, {
110227
- method: "POST",
110228
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
110229
- body: "{}",
110283
+ const { nonce, bearer } = shellRequestCredential(token);
110284
+ const post = CHANNELS_ROUTE_METHODS[route] === "POST";
110285
+ const response = await this.fetchImpl(url, {
110286
+ method: post ? "POST" : "GET",
110287
+ redirect: "manual",
110288
+ headers: post ? { authorization: `Bearer ${bearer}`, "content-type": "application/json" } : { authorization: `Bearer ${bearer}` },
110289
+ ...post ? { body: "{}" } : {},
110230
110290
  signal: AbortSignal.timeout(route === "channels-disconnect" ? this.requestTimeoutMs * DISCONNECT_TIMEOUT_FACTOR : this.requestTimeoutMs)
110231
110291
  });
110232
- }
110233
- async get(route) {
110234
- const { url, token } = this.target(route);
110235
- return this.fetchImpl(url, {
110236
- method: "GET",
110237
- headers: { authorization: `Bearer ${token}` },
110238
- signal: AbortSignal.timeout(this.requestTimeoutMs)
110239
- });
110292
+ return { response, token, nonce };
110240
110293
  }
110241
110294
  reachable() {
110242
110295
  try {
@@ -110250,9 +110303,11 @@ class ChannelsClient {
110250
110303
  }
110251
110304
  }
110252
110305
  async call(route, guard) {
110253
- let response;
110306
+ let sent;
110307
+ let text;
110254
110308
  try {
110255
- response = CHANNELS_ROUTE_METHODS[route] === "GET" ? await this.get(route) : await this.post(route);
110309
+ sent = await this.send(route);
110310
+ text = await sent.response.text();
110256
110311
  } catch (error) {
110257
110312
  if (error instanceof ServeDown) {
110258
110313
  return { ok: false, code: error.code, reason: error.message };
@@ -110266,9 +110321,13 @@ class ChannelsClient {
110266
110321
  reason: "keryx serve did not answer in time or dropped the connection, so the outcome is unknown. Check /channels, then retry if needed."
110267
110322
  };
110268
110323
  }
110324
+ const { response, token, nonce } = sent;
110325
+ if (!verifyServeResponseProof(token, nonce, route, response.status, text, response.headers.get(SERVE_PROOF_HEADER))) {
110326
+ return { ok: false, code: "unverified-serve", reason: UNVERIFIED_SERVE_REASON };
110327
+ }
110269
110328
  let parsed;
110270
110329
  try {
110271
- parsed = JSON.parse(await response.text());
110330
+ parsed = JSON.parse(text);
110272
110331
  } catch {
110273
110332
  parsed = undefined;
110274
110333
  }
@@ -110314,7 +110373,7 @@ function refusedConnection(error) {
110314
110373
  function isPairing(body) {
110315
110374
  return typeof body.state === "string" && typeof body.expiresAt === "number" && Array.isArray(body.problems);
110316
110375
  }
110317
- var DEFAULT_REQUEST_TIMEOUT_MS2 = 15000, DISCONNECT_TIMEOUT_FACTOR = 8, ServeDown;
110376
+ var DEFAULT_REQUEST_TIMEOUT_MS2 = 15000, UNVERIFIED_SERVE_REASON = "The program answering on keryx serve's port did not prove it is the keryx serve this shell trusts, so its answer was ignored and nothing was written. Restart `keryx serve` (an older serve cannot prove itself; update keryx on both sides).", DISCONNECT_TIMEOUT_FACTOR = 8, ServeDown;
110318
110377
  var init_channels_client = __esm(() => {
110319
110378
  init_serve_config();
110320
110379
  init_config_dir();
@@ -110688,7 +110747,7 @@ function channelsStatusLines(snapshot) {
110688
110747
  return [
110689
110748
  "Telegram: pairing in progress",
110690
110749
  ...machine,
110691
- "Resume shows the one-time code again; Cancel pairing abandons it and erases the token."
110750
+ "Resume shows the one-time code again, or connects a pairing that already finished; Cancel pairing abandons it and erases the token."
110692
110751
  ];
110693
110752
  case "connected":
110694
110753
  return [
@@ -162700,7 +162759,8 @@ async function importProjectSkills(options) {
162700
162759
  }
162701
162760
  const existingFlags = await projectReviewerFlags(options.projectRoot);
162702
162761
  const plans = await Promise.all(sources.map((source) => planOne(options, source)));
162703
- const ruleDecisions = await decideReferencedRules(options, sources.filter((source) => !skippedAsDeprecated(source)).filter((source) => options.force === true || !BUNDLED_NAMES.has(source.name)));
162762
+ const refusedPackages = new Set(plans.flatMap((plan) => ("row" in plan) && isRefusedStatus(plan.row.status) ? [plan.row.name] : []));
162763
+ const ruleDecisions = await decideReferencedRules(options, sources.filter((source) => !skippedAsDeprecated(source)).filter((source) => options.force === true || !BUNDLED_NAMES.has(source.name)), refusedPackages);
162704
162764
  await refuseUncontainedWrites(label, options.projectRoot, [
162705
162765
  ...plans.flatMap((plan) => ("write" in plan) ? projectSkillWritePaths(plan.write.module, plan.write.name, "single") : []),
162706
162766
  ...ruleDecisions.flatMap((decision) => ("placement" in decision) ? [decision.placement.target] : [])
@@ -162712,7 +162772,7 @@ async function importProjectSkills(options) {
162712
162772
  addFlagWarnings(imported, sources, existingFlags);
162713
162773
  const rules = [];
162714
162774
  for (const decision of ruleDecisions) {
162715
- rules.push("row" in decision ? decision.row : options.dryRun ? unwrittenRow(decision.placement, decision.dryRunReason) : await writePlacedRule(options, decision.placement));
162775
+ rules.push("row" in decision ? decision.row : options.dryRun ? unwrittenRow(decision.placement, [decision.dryRunReason, ...gateNotes(decision.gate, true)].filter(isString2).join(" \u2014 ") || undefined, decision.gate.security) : await writePlacedRule(options, decision.placement, decision.gate));
162716
162776
  }
162717
162777
  return {
162718
162778
  from: options.from,
@@ -162770,7 +162830,7 @@ function renderImportProjectSkillsMarkdown(result) {
162770
162830
  `from: ${result.from}`,
162771
162831
  `dry-run: ${result.dryRun ? "yes" : "no"}`,
162772
162832
  `force: ${result.force ? "yes" : "no"}`,
162773
- `imported: ${counts.imported} overwritten: ${counts.overwritten} updated: ${counts.updated} skipped: ${counts.skipped} would-import: ${counts["would-import"]} would-overwrite: ${counts["would-overwrite"]}`,
162833
+ `imported: ${counts.imported} overwritten: ${counts.overwritten} updated: ${counts.updated} skipped: ${counts.skipped} would-import: ${counts["would-import"]} would-overwrite: ${counts["would-overwrite"]}${counts.refused > 0 ? ` refused: ${counts.refused}` : ""}${counts["would-refuse"] > 0 ? ` would-refuse: ${counts["would-refuse"]}` : ""}`,
162774
162834
  ""
162775
162835
  ];
162776
162836
  if (result.dryRun) {
@@ -162795,6 +162855,9 @@ function renderImportProjectSkillsMarkdown(result) {
162795
162855
  if (result.imported.some((row) => row.status === "skipped" && row.reason === DEPRECATED_REASON)) {
162796
162856
  lines.push("A package whose frontmatter says `deprecated: true` is skipped in a tree import. To import one anyway, pass its own directory as --from.", "");
162797
162857
  }
162858
+ if (result.imported.some((row) => isRefusedStatus(row.status)) || result.rules.some((rule) => isRefusedStatus(rule.status))) {
162859
+ lines.push(refusedNote(result), "");
162860
+ }
162798
162861
  if (result.rules.length > 0) {
162799
162862
  lines.push("## rules the skills cite", "");
162800
162863
  for (const rule of result.rules) {
@@ -162806,10 +162869,10 @@ function renderImportProjectSkillsMarkdown(result) {
162806
162869
  lines.push("A reviewer that cites `<dir>/<name>.mdc` reads `.metaproject/rules/project/<dir>/<name>.mdc` when that file exists,", "and `.metaproject/rules/<dir>/<name>.mdc` otherwise. `keryx init`, `keryx update` and `keryx skills install` overwrite", "rules/core with keryx's own rules and leave rules/project alone. `keryx review reviewers` lists each such reference", "under `shadowedRules`.", "");
162807
162870
  }
162808
162871
  }
162809
- if (result.imported.some((row) => row.module === PROJECT_REVIEWER_MODULE && row.status !== "skipped")) {
162872
+ if (result.imported.some((row) => row.module === PROJECT_REVIEWER_MODULE && isWrittenRow(row))) {
162810
162873
  lines.push(`Reviewers: \`keryx review reviewers\` must list every imported ${PROJECT_REVIEWER_MODULE}/* name. That is the same call review-orchestrator makes.`);
162811
162874
  }
162812
- if (result.imported.some((row) => row.module !== PROJECT_REVIEWER_MODULE && row.status !== "skipped")) {
162875
+ if (result.imported.some((row) => row.module !== PROJECT_REVIEWER_MODULE && isWrittenRow(row))) {
162813
162876
  lines.push("Non-review modules are registered for `keryx skills route`. They are NOT injected into flow-orchestrator's fixed pipeline \u2014 name the skill in a dispatch if you want it there.");
162814
162877
  }
162815
162878
  return `${lines.join(`
@@ -162894,7 +162957,7 @@ function soleCarrierWarning(flag, overwritten, remaining) {
162894
162957
  return `flag ${flag} is dropped by this version of ${overwritten}, which leaves ${remaining} its only carrier: ${flag} now dispatches ${remaining} outright instead of selecting it path-gated.`;
162895
162958
  }
162896
162959
  function addFlagWarnings(rows, sources, existingFlags) {
162897
- const isWritten = (row) => row.module === PROJECT_REVIEWER_MODULE && row.status !== "skipped";
162960
+ const isWritten = (row) => row.module === PROJECT_REVIEWER_MODULE && isWrittenRow(row);
162898
162961
  const replaced = new Set(rows.filter(isWritten).map((row) => row.name));
162899
162962
  const flagsAfter = new Map(existingFlags);
162900
162963
  for (const [index, row] of rows.entries()) {
@@ -163106,6 +163169,110 @@ async function skillPackageDirs(root) {
163106
163169
  }
163107
163170
  return { dirs, tree: true };
163108
163171
  }
163172
+ function isRefusedStatus(status) {
163173
+ return status === "refused" || status === "would-refuse";
163174
+ }
163175
+ function refusedNote(result) {
163176
+ const refused = [...result.imported, ...result.rules].filter((row) => isRefusedStatus(row.status));
163177
+ const overridable = refused.some((row) => row.security?.findings.some((finding) => finding.policyId.startsWith("prompt-injection.")));
163178
+ return overridable ? `${REFUSED_NOTE} ${ALLOW_FLAGGED_HINT}` : REFUSED_NOTE;
163179
+ }
163180
+ function exitNonZeroOnRefusal(result) {
163181
+ if ([...result.imported, ...result.rules].some((row) => row.status === "refused")) {
163182
+ process.exitCode = 1;
163183
+ }
163184
+ }
163185
+ function isWrittenRow(row) {
163186
+ return row.status !== "skipped" && !isRefusedStatus(row.status);
163187
+ }
163188
+ async function gateText(projectRoot, text, target, allowFlagged) {
163189
+ const guard = await guardOutput({
163190
+ cwd: projectRoot,
163191
+ content: text,
163192
+ target: "skill",
163193
+ source: "untrusted-external",
163194
+ path: target
163195
+ });
163196
+ let findings = guard.decision.findings.map(({ policyId, category, action }) => ({ policyId, category, action }));
163197
+ let action = guard.decision.action;
163198
+ let warning = formatGuardWarning(guard.decision);
163199
+ if (guard.allowed && !await isSecurityEnabled(projectRoot)) {
163200
+ const own = detectInjection(text);
163201
+ if (own.length > 0) {
163202
+ findings = own.map(({ policyId, category }) => ({ policyId, category, action: "warn" }));
163203
+ action = "warn";
163204
+ warning = `[security] the security module is disabled, so the import ran its own injection check: ${own.length} finding(s) (prompt-injection:${own.length})`;
163205
+ }
163206
+ }
163207
+ const summary = findings;
163208
+ const security = (redacted, refused) => ({
163209
+ action,
163210
+ findings: summary,
163211
+ redacted,
163212
+ refused
163213
+ });
163214
+ const injection = findings.some((finding) => finding.policyId.startsWith("prompt-injection."));
163215
+ const blockedByMode = !guard.allowed && findings.length > 0;
163216
+ if (blockedByMode || injection && !allowFlagged) {
163217
+ return { kind: "refuse", reason: warning ?? guard.reason ?? "security gate blocked", security: security(false, true) };
163218
+ }
163219
+ const output = prepareOutputForPersistence(guard, text);
163220
+ if (!output.allowed)
163221
+ return { kind: "blocked", reason: output.reason };
163222
+ const redacted = !output.bytesPreserved;
163223
+ return {
163224
+ kind: "write",
163225
+ content: output.content,
163226
+ warning,
163227
+ overridden: injection,
163228
+ redacted,
163229
+ ...findings.length > 0 || redacted ? { security: security(redacted, false) } : {}
163230
+ };
163231
+ }
163232
+ function gateRowParts(gate, dryRun) {
163233
+ const detail = gate.warning !== null ? ` (${gate.warning})` : "";
163234
+ const security = gate.security !== undefined ? { security: gate.security } : {};
163235
+ if (gate.overridden) {
163236
+ const redacted = gate.redacted ? ", redacted" : "";
163237
+ return {
163238
+ reason: `flagged by the security gate${detail}${redacted}, ${dryRun ? "would be written" : "written"} because --allow-flagged`,
163239
+ ...security
163240
+ };
163241
+ }
163242
+ if (gate.redacted) {
163243
+ return { reason: `${dryRun ? "would be redacted" : "redacted"} by the security gate${detail}`, ...security };
163244
+ }
163245
+ if (gate.warning !== null) {
163246
+ return { reason: `flagged by the security gate${detail}`, ...security };
163247
+ }
163248
+ return security;
163249
+ }
163250
+ function gateNotes(gate, dryRun) {
163251
+ return [gateRowParts(gate, dryRun).reason].filter(isString2);
163252
+ }
163253
+ function plannedSkillText(content, header) {
163254
+ const version = parseSkillFrontmatter(content).metadataVersion ?? "0.1.0";
163255
+ return stampImportHeader(content, [
163256
+ `Version: ${version}`,
163257
+ `Target: ${header.name}`,
163258
+ `Module: ${header.module}`,
163259
+ `Origin: ${header.origin.trim()}`,
163260
+ `Origin Hash: ${hashOriginContent(content)}`,
163261
+ `Imported At: ${new Date().toISOString()}`,
163262
+ "Status: active",
163263
+ "Last Verified: never",
163264
+ ""
163265
+ ].join(`
163266
+ `));
163267
+ }
163268
+ function refusedSkillRow(base, verdict, dryRun) {
163269
+ return {
163270
+ ...base,
163271
+ status: dryRun ? "would-refuse" : "refused",
163272
+ reason: verdict.kind === "blocked" ? `blocked by the security gate: ${verdict.reason}` : verdict.reason,
163273
+ ...verdict.kind === "refuse" ? { security: verdict.security } : {}
163274
+ };
163275
+ }
163109
163276
  async function planOne(options, source) {
163110
163277
  const dest = projectSkillPackagePath(source.module, source.name);
163111
163278
  const destAbs = path320.join(options.projectRoot, dest, "SKILL.md");
@@ -163148,10 +163315,18 @@ async function planOne(options, source) {
163148
163315
  }
163149
163316
  };
163150
163317
  }
163151
- return { write: source, exists };
163318
+ const verdict = await gateText(options.projectRoot, plannedSkillText(source.content, { name: source.name, module: source.module, origin: source.origin }), toPosix(path320.join(dest, "SKILL.md")), options.allowFlagged === true);
163319
+ if (verdict.kind !== "write") {
163320
+ return {
163321
+ row: refusedSkillRow({ name: source.name, module: source.module, path: dest, origin: source.origin }, verdict, options.dryRun === true)
163322
+ };
163323
+ }
163324
+ return { write: source, exists, gate: verdict };
163152
163325
  }
163153
- async function writeOne(options, { write: source, exists }) {
163326
+ async function writeOne(options, { write: source, exists, gate }) {
163327
+ const notes = importNotes(source);
163154
163328
  if (options.dryRun) {
163329
+ const parts = gateRowParts(gate, true);
163155
163330
  return {
163156
163331
  name: source.name,
163157
163332
  module: source.module,
@@ -163159,7 +163334,7 @@ async function writeOne(options, { write: source, exists }) {
163159
163334
  path: projectSkillPackagePath(source.module, source.name),
163160
163335
  origin: source.origin,
163161
163336
  wired: wiringNote(source.module),
163162
- ...importNotes(source)
163337
+ ...mergeNotes(notes, parts)
163163
163338
  };
163164
163339
  }
163165
163340
  const created = await createProjectSkill(options.projectRoot, {
@@ -163172,7 +163347,7 @@ async function writeOne(options, { write: source, exists }) {
163172
163347
  ...versionOption(source.content),
163173
163348
  format: "single"
163174
163349
  });
163175
- await overwriteImportedSkill(options.projectRoot, created, source.content);
163350
+ await writeGatedSkill(options.projectRoot, created, gate);
163176
163351
  return {
163177
163352
  name: source.name,
163178
163353
  module: source.module,
@@ -163180,7 +163355,15 @@ async function writeOne(options, { write: source, exists }) {
163180
163355
  path: created.skillPath,
163181
163356
  origin: source.origin,
163182
163357
  wired: wiringNote(source.module),
163183
- ...importNotes(source)
163358
+ ...mergeNotes(notes, gateRowParts(gate, false))
163359
+ };
163360
+ }
163361
+ function mergeNotes(notes, parts) {
163362
+ return {
163363
+ ...notes.pathsSource !== undefined ? { pathsSource: notes.pathsSource } : {},
163364
+ ...notes.warnings !== undefined ? { warnings: notes.warnings } : {},
163365
+ ...parts.reason !== undefined ? { reason: parts.reason } : {},
163366
+ ...parts.security !== undefined ? { security: parts.security } : {}
163184
163367
  };
163185
163368
  }
163186
163369
  async function planUpdate(options, entry) {
@@ -163209,9 +163392,15 @@ async function planUpdate(options, entry) {
163209
163392
  content = await readFile163(resolved, "utf8");
163210
163393
  origin = portableOriginRef(resolved, options.projectRoot);
163211
163394
  }
163212
- return { entry, origin, content };
163395
+ const verdict = await gateText(options.projectRoot, plannedSkillText(content, { name: entry.name, module: entry.module, origin }), toPosix(path320.join(entry.path, "SKILL.md")), options.allowFlagged === true);
163396
+ if (verdict.kind !== "write") {
163397
+ return {
163398
+ row: refusedSkillRow({ name: entry.name, module: entry.module, path: entry.path, origin }, verdict, options.dryRun === true)
163399
+ };
163400
+ }
163401
+ return { entry, origin, content, gate: verdict };
163213
163402
  }
163214
- async function writeUpdate(options, { entry, origin, content }) {
163403
+ async function writeUpdate(options, { entry, origin, content, gate }) {
163215
163404
  if (options.dryRun) {
163216
163405
  return {
163217
163406
  name: entry.name,
@@ -163219,7 +163408,8 @@ async function writeUpdate(options, { entry, origin, content }) {
163219
163408
  status: "would-overwrite",
163220
163409
  path: entry.path,
163221
163410
  origin,
163222
- wired: wiringNote(entry.module)
163411
+ wired: wiringNote(entry.module),
163412
+ ...mergeNotes({}, gateRowParts(gate, true))
163223
163413
  };
163224
163414
  }
163225
163415
  const created = await createProjectSkill(options.projectRoot, {
@@ -163232,40 +163422,19 @@ async function writeUpdate(options, { entry, origin, content }) {
163232
163422
  ...versionOption(content),
163233
163423
  format: "single"
163234
163424
  });
163235
- await overwriteImportedSkill(options.projectRoot, created, content);
163425
+ await writeGatedSkill(options.projectRoot, created, gate);
163236
163426
  return {
163237
163427
  name: entry.name,
163238
163428
  module: entry.module,
163239
163429
  status: "updated",
163240
163430
  path: created.skillPath,
163241
163431
  origin,
163242
- wired: wiringNote(entry.module)
163432
+ wired: wiringNote(entry.module),
163433
+ ...mergeNotes({}, gateRowParts(gate, false))
163243
163434
  };
163244
163435
  }
163245
- async function overwriteImportedSkill(projectRoot, created, source) {
163246
- const relative = toPosix(path320.join(created.skillPath, "SKILL.md"));
163247
- const scaffold = await readFile163(path320.join(projectRoot, relative), "utf8");
163248
- const header = extractImportHeader(scaffold, parseSkillFrontmatter(source).metadataVersion);
163249
- const stamped = stampImportHeader(source, header);
163250
- const guard = await guardOutput({
163251
- cwd: projectRoot,
163252
- content: stamped,
163253
- target: "skill",
163254
- source: "untrusted-external",
163255
- path: relative
163256
- });
163257
- const output = prepareOutputForPersistence(guard, stamped);
163258
- if (!output.allowed) {
163259
- throw new Error(`Project skill blocked by the security gate: ${output.reason}`);
163260
- }
163261
- await writeContained(projectRoot, relative, output.content);
163262
- }
163263
- function extractImportHeader(scaffold, version) {
163264
- const lines = scaffold.split(`
163265
- `).filter((line) => IMPORT_HEADER_LINE.test(line)).map((line) => version && line.startsWith("Version:") ? `Version: ${version}` : line);
163266
- return lines.length > 0 ? `${lines.join(`
163267
- `)}
163268
- ` : "";
163436
+ async function writeGatedSkill(projectRoot, created, gate) {
163437
+ await writeContained(projectRoot, toPosix(path320.join(created.skillPath, "SKILL.md")), gate.content);
163269
163438
  }
163270
163439
  function stampImportHeader(source, header) {
163271
163440
  if (!header)
@@ -163340,8 +163509,9 @@ function collectRuleCitations(sources) {
163340
163509
  }
163341
163510
  return [...byRef.values()].map((citation) => ({ ...citation, citedBy: citation.citedBy.sort() })).sort((a, b) => a.ref.localeCompare(b.ref));
163342
163511
  }
163343
- function unwrittenRow(placement, reason) {
163512
+ function unwrittenRow(placement, reason, security) {
163344
163513
  const { ref, citedBy, origin, target } = placement;
163514
+ const gate = security !== undefined ? { security } : {};
163345
163515
  if (placement.existing !== undefined) {
163346
163516
  return {
163347
163517
  ref,
@@ -163351,17 +163521,43 @@ function unwrittenRow(placement, reason) {
163351
163521
  existing: placement.existing.path,
163352
163522
  target,
163353
163523
  written: false,
163354
- ...reason !== undefined ? { reason } : {}
163524
+ ...reason !== undefined ? { reason } : {},
163525
+ ...gate
163355
163526
  };
163356
163527
  }
163357
- return { ref, citedBy, status: placement.toProjectSlot ? "would-import-project" : "would-import", origin, target };
163528
+ return {
163529
+ ref,
163530
+ citedBy,
163531
+ status: placement.toProjectSlot ? "would-import-project" : "would-import",
163532
+ origin,
163533
+ target,
163534
+ ...reason !== undefined ? { reason } : {},
163535
+ ...gate
163536
+ };
163358
163537
  }
163359
- function writtenRow(placement) {
163538
+ function writtenRow(placement, gate) {
163360
163539
  const { ref, citedBy, origin, target } = placement;
163540
+ const notes = gateNotes(gate, false).join(" \u2014 ");
163541
+ const extra = {
163542
+ ...notes !== "" ? { reason: notes } : {},
163543
+ ...gate.security !== undefined ? { security: gate.security } : {}
163544
+ };
163361
163545
  if (placement.existing !== undefined) {
163362
- return { ref, citedBy, status: "differs", origin, existing: placement.existing.path, target, written: true };
163546
+ return { ref, citedBy, status: "differs", origin, existing: placement.existing.path, target, written: true, ...extra };
163363
163547
  }
163364
- return { ref, citedBy, status: placement.toProjectSlot ? "imported-project" : "imported", origin, target };
163548
+ return { ref, citedBy, status: placement.toProjectSlot ? "imported-project" : "imported", origin, target, ...extra };
163549
+ }
163550
+ function refusedRuleRow(placement, verdict, dryRun) {
163551
+ const { ref, citedBy, origin } = placement;
163552
+ return {
163553
+ ref,
163554
+ citedBy,
163555
+ status: dryRun ? "would-refuse" : "refused",
163556
+ origin,
163557
+ ...placement.existing !== undefined ? { existing: placement.existing.path } : {},
163558
+ reason: verdict.reason,
163559
+ security: verdict.security
163560
+ };
163365
163561
  }
163366
163562
  async function decideRule(options, citation, bundledRules) {
163367
163563
  const base = { ref: citation.ref, citedBy: citation.citedBy };
@@ -163410,32 +163606,35 @@ async function decideRule(options, citation, bundledRules) {
163410
163606
  if (replacesProjectCopy && options.force !== true) {
163411
163607
  return { row: unwrittenRow(placement, "left as it is; pass --force to replace it with the overlay's") };
163412
163608
  }
163413
- return replacesProjectCopy ? { placement, dryRunReason: "would be replaced with the overlay's (--force)" } : { placement };
163414
- }
163415
- async function writePlacedRule(options, placement) {
163416
- const { ref, citedBy, origin, target } = placement;
163417
- const guard = await guardOutput({
163418
- cwd: options.projectRoot,
163419
- content: placement.content,
163420
- target: "skill",
163421
- source: "untrusted-external",
163422
- path: target
163423
- });
163424
- const output = prepareOutputForPersistence(guard, placement.content);
163425
- if (!output.allowed) {
163426
- const reason = `blocked by the security gate: ${output.reason}`;
163427
- return placement.existing !== undefined ? unwrittenRow(placement, reason) : { ref, citedBy, status: "unresolved", origin, reason };
163609
+ const verdict = await gateText(options.projectRoot, content, target, options.allowFlagged === true);
163610
+ if (verdict.kind === "refuse") {
163611
+ return { row: refusedRuleRow(placement, verdict, options.dryRun === true) };
163428
163612
  }
163429
- if (placement.existing?.content === output.content) {
163430
- return { ref, citedBy, status: "present", origin, existing: placement.existing.path };
163613
+ if (verdict.kind === "blocked") {
163614
+ const reason = `blocked by the security gate: ${verdict.reason}`;
163615
+ return {
163616
+ row: placement.existing !== undefined ? unwrittenRow(placement, reason) : { ...base, status: "unresolved", origin, reason }
163617
+ };
163618
+ }
163619
+ if (placement.existing?.content === verdict.content) {
163620
+ return { row: { ...base, status: "present", origin, existing: placement.existing.path } };
163431
163621
  }
163432
- await writeContained(options.projectRoot, target, output.content);
163433
- return writtenRow(placement);
163622
+ return replacesProjectCopy ? { placement, gate: verdict, dryRunReason: "would be replaced with the overlay's (--force)" } : { placement, gate: verdict };
163434
163623
  }
163435
- async function decideReferencedRules(options, sources) {
163624
+ async function writePlacedRule(options, placement, gate) {
163625
+ await writeContained(options.projectRoot, placement.target, gate.content);
163626
+ return writtenRow(placement, gate);
163627
+ }
163628
+ async function decideReferencedRules(options, sources, refusedPackages) {
163436
163629
  const bundledRules = bundledRulesSourcePath();
163437
163630
  const decisions = [];
163438
163631
  for (const citation of collectRuleCitations(sources)) {
163632
+ if (citation.citedBy.every((name) => refusedPackages.has(name))) {
163633
+ decisions.push({
163634
+ row: { ref: citation.ref, citedBy: citation.citedBy, status: "skipped", reason: "cited only by a refused package" }
163635
+ });
163636
+ continue;
163637
+ }
163439
163638
  decisions.push(await decideRule(options, citation, bundledRules));
163440
163639
  }
163441
163640
  return decisions;
@@ -163495,7 +163694,9 @@ function countByStatus(rows) {
163495
163694
  skipped: 0,
163496
163695
  "would-import": 0,
163497
163696
  "would-overwrite": 0,
163498
- updated: 0
163697
+ updated: 0,
163698
+ refused: 0,
163699
+ "would-refuse": 0
163499
163700
  };
163500
163701
  for (const row of rows) {
163501
163702
  counts[row.status] += 1;
@@ -163521,13 +163722,15 @@ async function runSkillsImportCommand(args) {
163521
163722
  ...skillName !== undefined ? { name: skillName } : {},
163522
163723
  only: onlyOption(args, "keryx skills import"),
163523
163724
  dryRun: args.includes("--dry-run"),
163524
- force: args.includes("--force")
163725
+ force: args.includes("--force"),
163726
+ allowFlagged: args.includes(ALLOW_FLAGGED_FLAG)
163525
163727
  });
163526
163728
  if (args.includes("--json")) {
163527
163729
  console.log(JSON.stringify(result, null, 2));
163528
- return;
163730
+ } else {
163731
+ console.log(renderImportProjectSkillsMarkdown(result));
163529
163732
  }
163530
- console.log(renderImportProjectSkillsMarkdown(result));
163733
+ exitNonZeroOnRefusal(result);
163531
163734
  }
163532
163735
  async function runSkillsUpdateCommand(args) {
163533
163736
  if (args.includes("--help") || args.includes("-h")) {
@@ -163541,13 +163744,15 @@ async function runSkillsUpdateCommand(args) {
163541
163744
  ...positional[0] !== undefined ? { skill: positional[0] } : {},
163542
163745
  all: args.includes("--all"),
163543
163746
  ...from !== undefined ? { from } : {},
163544
- dryRun: args.includes("--dry-run")
163747
+ dryRun: args.includes("--dry-run"),
163748
+ allowFlagged: args.includes(ALLOW_FLAGGED_FLAG)
163545
163749
  });
163546
163750
  if (args.includes("--json")) {
163547
163751
  console.log(JSON.stringify(result, null, 2));
163548
- return;
163752
+ } else {
163753
+ console.log(renderImportProjectSkillsMarkdown(result));
163549
163754
  }
163550
- console.log(renderImportProjectSkillsMarkdown(result));
163755
+ exitNonZeroOnRefusal(result);
163551
163756
  }
163552
163757
  function printSkillsImportHelp() {
163553
163758
  console.log(`keryx skills import
@@ -163558,7 +163763,7 @@ detectable.
163558
163763
 
163559
163764
  Usage:
163560
163765
  keryx skills import --from <dir|SKILL.md|https-url> [--module <module>] [--name <name>]
163561
- [--only <glob>]... [--dry-run] [--force] [--json]
163766
+ [--only <glob>]... [--dry-run] [--force] [--allow-flagged] [--json]
163562
163767
 
163563
163768
  --from:
163564
163769
  a skill package directory, a SKILL.md file, a parent tree that contains
@@ -163592,6 +163797,8 @@ skipped. Pass its own directory as --from to import it anyway.
163592
163797
 
163593
163798
  A name that collides with a bundled keryx skill is skipped unless --force.
163594
163799
 
163800
+ ${SECURITY_GATE_HELP}
163801
+
163595
163802
  A package landing in module \`review\` is imported with a warning for each of:
163596
163803
  - neither \`metadata.paths\` nor a glob in its description (a file path named
163597
163804
  without a glob is not a trigger): it is dispatched on every round.
@@ -163639,8 +163846,10 @@ function printSkillsUpdateHelp() {
163639
163846
  Re-read a project-skill's Origin and overwrite SKILL.md when the source moved on.
163640
163847
 
163641
163848
  Usage:
163642
- keryx skills update <module>/<name> [--from <new-origin>] [--dry-run] [--json]
163643
- keryx skills update --all [--dry-run] [--json]
163849
+ keryx skills update <module>/<name> [--from <new-origin>] [--dry-run] [--allow-flagged] [--json]
163850
+ keryx skills update --all [--dry-run] [--allow-flagged] [--json]
163851
+
163852
+ ${SECURITY_GATE_HELP}
163644
163853
 
163645
163854
  Before anything is written, every destination is checked for a symlink on the
163646
163855
  way that resolves outside the project, as \`keryx skills import\` does. One is
@@ -163654,7 +163863,19 @@ Examples:
163654
163863
  var BUNDLED_NAMES, PATHS_NONE_WARNING = 'paths: none \u2014 dispatched on every round. Declare `metadata.paths: "<glob>, <glob>"` in its frontmatter to gate it on the diff.', DEFAULT_FETCHER = async (url) => {
163655
163864
  const response = await fetch(url);
163656
163865
  return { ok: response.ok, status: response.status, text: await response.text() };
163657
- }, DEPRECATED_REASON = "deprecated", KNOWN_CATEGORIES, IMPORT_HEADER_LABELS, IMPORT_HEADER_LINE, BYTE_ORDER_MARK = 65279;
163866
+ }, DEPRECATED_REASON = "deprecated", KNOWN_CATEGORIES, isString2 = (value) => value !== undefined, REFUSED_NOTE = "A refused file was not written.", ALLOW_FLAGGED_HINT = "A prompt-injection refusal can be overridden after you read the file: run the command again with --allow-flagged.", IMPORT_HEADER_LABELS, IMPORT_HEADER_LINE, BYTE_ORDER_MARK = 65279, ALLOW_FLAGGED_FLAG = "--allow-flagged", SECURITY_GATE_HELP = `Security gate: a SKILL.md or rule is written as the gate returns it, so a secret is
163867
+ masked and the row says it was redacted, and any other finding is reported on the
163868
+ row. Text with a prompt-injection finding is refused in every security mode: the
163869
+ row is \`refused\` (\`would-refuse\` on a dry run) with the gate's own summary,
163870
+ nothing is written, and the other packages and rules go on. After reading the
163871
+ file, --allow-flagged writes it anyway and the row says it was flagged and written
163872
+ because of the flag. It does not override a block by the project's security mode:
163873
+ under mode enforced a secret is refused either way. With the security module
163874
+ disabled the import runs the injection check itself, so injection text is refused
163875
+ there too (the row says the module is disabled); a secret is still masked. A rule
163876
+ cited only by refused packages is not written (\`skipped \u2014 cited only by a refused
163877
+ package\`). A real run that refused anything exits 1 after printing every row; a
163878
+ dry run (\`would-refuse\`) exits 0. --json rows carry a \`security\` object.`;
163658
163879
  var init_import_skills = __esm(() => {
163659
163880
  init_args();
163660
163881
  init_contained_write();
@@ -163666,6 +163887,7 @@ var init_import_skills = __esm(() => {
163666
163887
  init_project_skills();
163667
163888
  init_project_reviewers();
163668
163889
  init_reviewer_triggers();
163890
+ init_injection();
163669
163891
  init_guard2();
163670
163892
  BUNDLED_NAMES = new Set(BUNDLED_GDSKILLS.map((entry) => entry.name));
163671
163893
  KNOWN_CATEGORIES = new Set(["review", "quality", "orchestration", "planning", "platform", "core"]);
@@ -167565,7 +167787,8 @@ async function importOverlayReviewers(options) {
167565
167787
  commandLabel: REVIEW_IMPORT_LABEL,
167566
167788
  ...options.only !== undefined ? { only: options.only } : {},
167567
167789
  ...options.dryRun !== undefined ? { dryRun: options.dryRun } : {},
167568
- ...options.force !== undefined ? { force: options.force } : {}
167790
+ ...options.force !== undefined ? { force: options.force } : {},
167791
+ ...options.allowFlagged !== undefined ? { allowFlagged: options.allowFlagged } : {}
167569
167792
  });
167570
167793
  }
167571
167794
  async function runImportReviewers(args) {
@@ -167583,13 +167806,15 @@ async function runImportReviewers(args) {
167583
167806
  from,
167584
167807
  only: onlyOption(args, REVIEW_IMPORT_LABEL),
167585
167808
  dryRun: args.includes("--dry-run"),
167586
- force: args.includes("--force")
167809
+ force: args.includes("--force"),
167810
+ allowFlagged: args.includes(ALLOW_FLAGGED_FLAG)
167587
167811
  });
167588
167812
  if (args.includes("--json")) {
167589
167813
  console.log(JSON.stringify({ ...result, inventory: await collectReviewers(process.cwd()) }, null, 2));
167590
- return;
167814
+ } else {
167815
+ console.log(renderImportProjectSkillsMarkdown(result));
167591
167816
  }
167592
- console.log(renderImportProjectSkillsMarkdown(result));
167817
+ exitNonZeroOnRefusal(result);
167593
167818
  }
167594
167819
  function printImportHelp() {
167595
167820
  console.log(`keryx review import
@@ -167601,7 +167826,7 @@ The review-shaped spelling of:
167601
167826
  Same importer, same rules; the module is implied.
167602
167827
 
167603
167828
  Usage:
167604
- keryx review import --from <package-dir|tree> [--only <glob>]... [--dry-run] [--force] [--json]
167829
+ keryx review import --from <package-dir|tree> [--only <glob>]... [--dry-run] [--force] [--allow-flagged] [--json]
167605
167830
 
167606
167831
  --from:
167607
167832
  one reviewer package directory, or a tree that holds several (a directory of
@@ -167623,6 +167848,8 @@ In a tree import a package whose frontmatter says \`deprecated: true\` is
167623
167848
  skipped; pass its own directory as --from to import it anyway. A name that
167624
167849
  collides with a bundled keryx skill is skipped unless --force.
167625
167850
 
167851
+ ${SECURITY_GATE_HELP}
167852
+
167626
167853
  A reviewer is imported with a warning for each of:
167627
167854
  - neither \`metadata.paths\` nor a glob in its description (a file path named
167628
167855
  without a glob is not a trigger): it is dispatched on every round.
@@ -172033,7 +172260,7 @@ var init_review = __esm(() => {
172033
172260
  COMPLETE_FLAGS = ["--finding", "--disposition", "--evidence"];
172034
172261
  STACK_FLAGS = ["--json"];
172035
172262
  REVIEWERS_FLAGS = ["--json"];
172036
- IMPORT_FLAGS = ["--from", "--only", "--dry-run", "--force", "--json", "--help"];
172263
+ IMPORT_FLAGS = ["--from", "--only", "--dry-run", "--force", "--allow-flagged", "--json", "--help"];
172037
172264
  SESSION_SOURCE_LABELS = {
172038
172265
  flags: "flags (--session-provider/--session-model)",
172039
172266
  env: "environment (KERYX_SESSION_PROVIDER/KERYX_SESSION_MODEL)",
@@ -187025,6 +187252,28 @@ class RemoteHttpSurface {
187025
187252
  return this.options.verifyShellToken(presented);
187026
187253
  }
187027
187254
  async handle(route, request, io) {
187255
+ const response = await this.dispatch(route, request, io);
187256
+ const authorization = request.headers.get("authorization") ?? "";
187257
+ const nonce = /^Bearer /i.test(authorization) ? derivedBearerNonce(authorization.slice(7).trim()) : undefined;
187258
+ if (nonce === undefined || this.options.proveResponse === undefined) {
187259
+ return response;
187260
+ }
187261
+ if (route === "stream") {
187262
+ const proof = this.options.proveResponse(nonce, route, response.status, "");
187263
+ if (proof !== undefined) {
187264
+ response.headers.set(SERVE_PROOF_HEADER, proof);
187265
+ }
187266
+ return response;
187267
+ }
187268
+ const body = await response.text();
187269
+ const proof = this.options.proveResponse(nonce, route, response.status, body);
187270
+ const headers = new Headers(response.headers);
187271
+ if (proof !== undefined) {
187272
+ headers.set(SERVE_PROOF_HEADER, proof);
187273
+ }
187274
+ return new Response(body, { status: response.status, headers });
187275
+ }
187276
+ async dispatch(route, request, io) {
187028
187277
  if (isChannelsRoute(route)) {
187029
187278
  if (this.closed || this.channels === undefined) {
187030
187279
  return fail4(503, "remote-unavailable", "channels are not available in this serve");
@@ -187471,6 +187720,7 @@ var init_http_surface = __esm(() => {
187471
187720
  init_service2();
187472
187721
  init_hub();
187473
187722
  init_protocol2();
187723
+ init_shell_token();
187474
187724
  CONTROL_CHARS2 = /[\u0000-\u001f\u007f]/;
187475
187725
  JSON_HEADERS2 = { "content-type": "application/json; charset=utf-8" };
187476
187726
  });
@@ -187499,6 +187749,7 @@ class Pairing {
187499
187749
  expiresAt;
187500
187750
  ttlMs;
187501
187751
  earlyGroups = new Map;
187752
+ earlyMigrations = [];
187502
187753
  userId;
187503
187754
  chatId;
187504
187755
  chatTitle;
@@ -187510,6 +187761,7 @@ class Pairing {
187510
187761
  timers;
187511
187762
  expiryTimer;
187512
187763
  finished = false;
187764
+ begun = false;
187513
187765
  queue = Promise.resolve();
187514
187766
  constructor(options, bot) {
187515
187767
  this.options = options;
@@ -187529,6 +187781,13 @@ class Pairing {
187529
187781
  });
187530
187782
  }
187531
187783
  static async open(options) {
187784
+ const prepared = await Pairing.prepare(options);
187785
+ if (prepared.ok) {
187786
+ prepared.pairing.begin();
187787
+ }
187788
+ return prepared;
187789
+ }
187790
+ static async prepare(options) {
187532
187791
  let bot;
187533
187792
  try {
187534
187793
  bot = await options.api.getMe();
@@ -187538,11 +187797,14 @@ class Pairing {
187538
187797
  }
187539
187798
  return { ok: false, kind: "unreachable", reason: `could not reach Telegram: ${describe3(error)}` };
187540
187799
  }
187541
- const pairing = new Pairing(options, bot);
187542
- pairing.begin();
187543
- return { ok: true, pairing };
187800
+ return { ok: true, pairing: new Pairing(options, bot) };
187544
187801
  }
187545
187802
  begin() {
187803
+ if (this.begun || FINAL.has(this.state)) {
187804
+ return;
187805
+ }
187806
+ this.begun = true;
187807
+ this.expiresAt = this.now() + this.ttlMs;
187546
187808
  this.armExpiryTimer();
187547
187809
  this.poller.start();
187548
187810
  }
@@ -187572,10 +187834,17 @@ class Pairing {
187572
187834
  this.expireIfDue();
187573
187835
  return FINAL.has(this.state);
187574
187836
  }
187837
+ isReady() {
187838
+ this.expireIfDue();
187839
+ return this.state === "ready";
187840
+ }
187575
187841
  async recheck() {
187576
187842
  if (this.state === "waiting-for-group" && this.candidate !== undefined) {
187577
- const chatId = this.candidate;
187578
- await this.enqueue(() => this.inspectGroup(chatId));
187843
+ await this.enqueue(async () => {
187844
+ if (this.state === "waiting-for-group" && this.candidate !== undefined) {
187845
+ await this.inspectGroup(this.candidate);
187846
+ }
187847
+ });
187579
187848
  }
187580
187849
  }
187581
187850
  async cancel() {
@@ -187639,6 +187908,7 @@ class Pairing {
187639
187908
  await this.takeCode(update);
187640
187909
  } else if (this.state === "waiting-for-group") {
187641
187910
  await this.takeGroup(update);
187911
+ await this.takeMigration(update);
187642
187912
  }
187643
187913
  }
187644
187914
  });
@@ -187664,15 +187934,31 @@ class Pairing {
187664
187934
  return;
187665
187935
  });
187666
187936
  const early = [...this.earlyGroups.values()];
187937
+ const earlyMoves = this.earlyMigrations;
187667
187938
  this.earlyGroups.clear();
187939
+ this.earlyMigrations = [];
187668
187940
  for (const event of early) {
187669
187941
  if (this.state !== "waiting-for-group") {
187670
187942
  break;
187671
187943
  }
187672
187944
  await this.takeGroup(event);
187673
187945
  }
187946
+ for (const event of earlyMoves) {
187947
+ if (this.state !== "waiting-for-group") {
187948
+ break;
187949
+ }
187950
+ await this.takeMigration(event);
187951
+ }
187674
187952
  }
187675
187953
  rememberGroupEvent(update) {
187954
+ const message = update.message;
187955
+ if (message !== undefined && (typeof message.migrate_to_chat_id === "number" || typeof message.migrate_from_chat_id === "number")) {
187956
+ this.earlyMigrations.push(update);
187957
+ if (this.earlyMigrations.length > EARLY_GROUP_EVENTS) {
187958
+ this.earlyMigrations.shift();
187959
+ }
187960
+ return;
187961
+ }
187676
187962
  const change = update.my_chat_member;
187677
187963
  if (change === undefined) {
187678
187964
  return;
@@ -187705,6 +187991,23 @@ class Pairing {
187705
187991
  }
187706
187992
  await this.inspectGroup(change.chat.id);
187707
187993
  }
187994
+ async takeMigration(update) {
187995
+ const message = update.message;
187996
+ if (message === undefined || this.candidate === undefined || FINAL.has(this.state)) {
187997
+ return;
187998
+ }
187999
+ let next;
188000
+ if (typeof message.migrate_to_chat_id === "number" && message.chat.id === this.candidate) {
188001
+ next = message.migrate_to_chat_id;
188002
+ } else if (typeof message.migrate_from_chat_id === "number" && message.migrate_from_chat_id === this.candidate) {
188003
+ next = message.chat.id;
188004
+ }
188005
+ if (next === undefined || next === this.candidate) {
188006
+ return;
188007
+ }
188008
+ this.candidate = next;
188009
+ await this.inspectGroup(next);
188010
+ }
187708
188011
  async inspectGroup(chatId) {
187709
188012
  if (FINAL.has(this.state)) {
187710
188013
  return;
@@ -187800,7 +188103,7 @@ class ChannelsController {
187800
188103
  if (this.host.hub() !== undefined) {
187801
188104
  return { state: "connected" };
187802
188105
  }
187803
- if (this.pairing !== undefined && !this.pairing.isFinal()) {
188106
+ if (this.pairing !== undefined && (!this.pairing.isFinal() || this.pairing.isReady())) {
187804
188107
  return { state: "pairing" };
187805
188108
  }
187806
188109
  if (this.configured()) {
@@ -187831,12 +188134,11 @@ class ChannelsController {
187831
188134
  if (this.host.hub() !== undefined) {
187832
188135
  return fail4(409, "already-connected", "Telegram is already connected on this machine; disconnect it first.");
187833
188136
  }
187834
- await this.dropPairing();
187835
188137
  const api = this.host.openApi();
187836
188138
  if (!api.ok) {
187837
188139
  return fail4(422, "no-token", api.reason);
187838
188140
  }
187839
- const opened = await Pairing.open({
188141
+ const opened = await Pairing.prepare({
187840
188142
  api: api.api,
187841
188143
  ...this.options.now === undefined ? {} : { now: this.options.now },
187842
188144
  ...this.options.pairing
@@ -187845,9 +188147,13 @@ class ChannelsController {
187845
188147
  return fail4(opened.kind === "rejected" ? 422 : 502, opened.kind === "rejected" ? "token-rejected" : "telegram-unreachable", opened.reason);
187846
188148
  }
187847
188149
  if (mine !== this.generation) {
187848
- await opened.pairing.cancel();
187849
188150
  return superseded();
187850
188151
  }
188152
+ await this.dropPairing();
188153
+ if (mine !== this.generation) {
188154
+ return superseded();
188155
+ }
188156
+ opened.pairing.begin();
187851
188157
  this.pairing = opened.pairing;
187852
188158
  return ok2({ ...opened.pairing.snapshot() });
187853
188159
  });
@@ -187872,13 +188178,13 @@ class ChannelsController {
187872
188178
  reload() {
187873
188179
  this.generation += 1;
187874
188180
  return this.exclusive(async () => {
187875
- await this.dropPairing();
187876
188181
  const config = loadRemoteConfig(this.host.dir);
187877
188182
  const token = loadBotToken(this.host.dir);
187878
188183
  if (!config.ok || !token.ok) {
187879
188184
  const reason = !config.ok ? config.reason : !token.ok ? token.reason : "";
187880
188185
  return fail4(422, "cannot-connect", this.host.hub() === undefined ? reason : `${reason} The running channel was left as it was.`);
187881
188186
  }
188187
+ await this.dropPairing();
187882
188188
  if (this.host.hub() !== undefined) {
187883
188189
  await this.host.stopHub("reloading remote control");
187884
188190
  }
@@ -188052,8 +188358,10 @@ function openRemoteService(options = {}) {
188052
188358
  });
188053
188359
  const machine = options.machine ?? localMachineName();
188054
188360
  let verifyShellToken;
188361
+ let shellToken;
188055
188362
  const surface = new RemoteHttpSurface({
188056
188363
  verifyShellToken: (presented) => verifyShellToken?.(presented) ?? false,
188364
+ proveResponse: (nonce, route, status, body) => shellToken === undefined ? undefined : serveResponseProof(shellToken, nonce, route, status, body),
188057
188365
  ...options.now === undefined ? {} : { now: options.now },
188058
188366
  ...options.timers === undefined ? {} : { timers: options.timers },
188059
188367
  ...options.surface
@@ -188219,11 +188527,15 @@ function openRemoteService(options = {}) {
188219
188527
  return refuse(lock.reason);
188220
188528
  }
188221
188529
  }
188530
+ try {
188531
+ unlinkSync11(endpointPath(options.dir));
188532
+ } catch {}
188222
188533
  const token = mintShellToken(options.dir);
188223
188534
  if (!token.ok) {
188224
188535
  return refuse(token.reason);
188225
188536
  }
188226
188537
  verifyShellToken = createShellTokenVerifier(token.value);
188538
+ shellToken = token.value;
188227
188539
  try {
188228
188540
  ensureRemoteDir(options.dir);
188229
188541
  const body = { address: endpoint.address, port: endpoint.port, pid: process.pid };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.3.51",
3
+ "version": "0.3.52",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -171,9 +171,10 @@ Read three things off it before running for real:
171
171
  the reviewer will cite a rule the project will not have. `differs` means a
172
172
  file already answers that reference with other content.
173
173
 
174
- A dry-run does not run the security gate that a real run applies to every file
175
- it writes, so a `would-…` status can be optimistic: the real run may still block
176
- a package, or report a rule as `unresolved` with `blocked by the security gate`.
174
+ A dry-run runs the same security gate a real run does, on the text that would
175
+ be written, so a `would-…` status already says what the gate would do. A file
176
+ the gate would refuse is `would-refuse`, and a file it would change or flag
177
+ carries the reason on its row (section 3, "The security gate on import").
177
178
 
178
179
  ### 3. The real run, and what it writes
179
180
 
@@ -305,6 +306,107 @@ to overwrite`, and the rule rows are re-resolved — `present` for both files
305
306
  written above, the second with `the reviewer reads
306
307
  .metaproject/rules/project/core/error-handling.mdc`.
307
308
 
309
+ **The security gate on import.** `keryx skills import`, `keryx review import`
310
+ and `keryx skills update` pass every `SKILL.md` and rule through the security
311
+ gate on the final text, while planning, so a dry run reports it too. The gate
312
+ does not write silently:
313
+
314
+ - A secret is masked in what is written, and the row says so: `redacted by the
315
+ security gate (…)`. On a dry run, `would be redacted by the security gate (…)`.
316
+ - Any other finding follows the project's security mode and is reported on the
317
+ row as `flagged by the security gate (…)`.
318
+ - A **prompt-injection** finding refuses the file in every security mode. The row
319
+ status is `refused` (`would-refuse` on a dry run), the reason is the gate's own
320
+ leak-safe summary (policy ids and counts, never the matched text), nothing is
321
+ written and no scaffold is left. The other packages and rules go on.
322
+ - **With the security module disabled** the guard reports nothing, so the import
323
+ runs the deterministic prompt-injection detector itself and refuses on a match
324
+ exactly as above; the row reason says `the security module is disabled, so the
325
+ import ran its own injection check`. A secret is still masked by the floor
326
+ redaction; clean text imports as usual.
327
+ - A rule cited **only** by refused (`would-refuse`) packages is not written. Its
328
+ row reads `skipped — cited only by a refused package`, on a dry run and a real
329
+ run alike; a rule an imported package also cites is handled as before.
330
+ - **Exit code.** A real run that produced any `refused` row prints every row (text
331
+ or `--json`) and then exits 1. A dry run, whose rows say `would-refuse`, exits 0.
332
+ - The file is gated once, before anything is written, and the text written is the
333
+ text gated: a refusal leaves no scaffold, registry entry or catalog row, and
334
+ never overwrites an installed `SKILL.md` on `--force` or update.
335
+ - `--allow-flagged` writes a prompt-injection-flagged file anyway, after you have
336
+ read it. The row then reads `flagged by the security gate (…), written because
337
+ --allow-flagged` (`would be written because --allow-flagged` on a dry run). It
338
+ never overrides a block by the project's security mode: under mode `enforced`
339
+ a secret is refused with or without the flag.
340
+
341
+ The flag belongs to all three commands: `keryx skills import … [--allow-flagged]`,
342
+ `keryx review import … [--allow-flagged]`, `keryx skills update … [--allow-flagged]`.
343
+ The summary line gains `refused: N` (`would-refuse: N` on a dry run) only when
344
+ N is above zero, and the note under it names `--allow-flagged` only when a refused
345
+ row carries a prompt-injection finding (the flag cannot change any other
346
+ refusal). This is the output for an overlay with two reviewers, one whose
347
+ body tries to override the agent's instructions and asks it to disclose its
348
+ own context, and one that holds the AWS documentation placeholder access key:
349
+
350
+ ```
351
+ $ keryx review import --from ./ov --only 'review-acme-*' --dry-run
352
+ # skills import
353
+
354
+ from: ./ov
355
+ dry-run: yes
356
+ force: no
357
+ imported: 0 overwritten: 0 updated: 0 skipped: 0 would-import: 1 would-overwrite: 0 would-refuse: 1
358
+ …
359
+ ## packages
360
+
361
+ - review/review-acme-inj: would-refuse — [security] pass: 2 finding(s) (prompt-injection:2)
362
+ - review/review-acme-key: would-import — would be redacted by the security gate ([security] fail: 1 finding(s) (secret:1)) — review-orchestrator will dispatch this after `keryx review reviewers` lists it
363
+
364
+ A refused file was not written. A prompt-injection refusal can be overridden after you read the file: run the command again with --allow-flagged.
365
+ …
366
+
367
+ $ keryx review import --from ./ov --only 'review-acme-*'
368
+ …
369
+ imported: 1 overwritten: 0 updated: 0 skipped: 0 would-import: 0 would-overwrite: 0 refused: 1
370
+
371
+ - review/review-acme-inj: refused — [security] pass: 2 finding(s) (prompt-injection:2)
372
+ - review/review-acme-key: imported — redacted by the security gate ([security] fail: 1 finding(s) (secret:1)) — review-orchestrator will dispatch this after `keryx review reviewers` lists it
373
+ …
374
+
375
+ $ keryx review import --from ./ov --only 'review-acme-inj' --dry-run --allow-flagged
376
+ …
377
+ - review/review-acme-inj: would-import — flagged by the security gate ([security] pass: 2 finding(s) (prompt-injection:2)), would be written because --allow-flagged — review-orchestrator will dispatch this after `keryx review reviewers` lists it
378
+ …
379
+ ```
380
+
381
+ Row statuses, with the gate in them: `imported`, `overwritten`, `updated`,
382
+ `skipped`, `would-import`, `would-overwrite`, `refused`, `would-refuse`.
383
+
384
+ In `--json` a row carries a `security` object when the gate had something to say
385
+ about it, and none otherwise. The refused row above, trimmed:
386
+
387
+ ```json
388
+ {
389
+ "name": "review-acme-inj",
390
+ "module": "review",
391
+ "status": "would-refuse",
392
+ "reason": "[security] pass: 2 finding(s) (prompt-injection:2)",
393
+ "security": {
394
+ "action": "warn",
395
+ "findings": [
396
+ { "policyId": "prompt-injection.ignore-instructions", "category": "prompt-injection", "action": "warn" },
397
+ …
398
+ ],
399
+ "redacted": false,
400
+ "refused": true
401
+ }
402
+ }
403
+ ```
404
+
405
+ `security.redacted` is true when the gate changed the bytes that are, or would
406
+ be, written; `security.refused` is true when the file was not (or would not be)
407
+ written. A caller that used to read `status` alone must now treat `refused` and
408
+ `would-refuse` as failures to import that exit 0.
409
+
308
410
  ### 4. Reading `keryx review reviewers`
309
411
 
310
412
  ```