@wrongstack/core 0.306.2 → 0.306.4

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.
Files changed (41) hide show
  1. package/dist/coordination/index.js +184 -86
  2. package/dist/coordination/mail-tools.d.ts +1 -1
  3. package/dist/coordination/mailbox-project-server.js +38 -16
  4. package/dist/coordination/sqlite-mailbox.d.ts +1 -0
  5. package/dist/core/conversation-state.d.ts +1 -2
  6. package/dist/core/fallback-model.d.ts +19 -1
  7. package/dist/core/fallback-profile-manager.d.ts +21 -3
  8. package/dist/core/index.d.ts +1 -1
  9. package/dist/core/index.js +152 -46
  10. package/dist/defaults/index.js +347 -139
  11. package/dist/execution/auto-compaction-middleware.d.ts +64 -0
  12. package/dist/execution/compaction-core.d.ts +4 -0
  13. package/dist/execution/index.d.ts +1 -1
  14. package/dist/execution/index.js +234 -91
  15. package/dist/execution/retry-policy.d.ts +27 -0
  16. package/dist/hq/index.js +50 -15
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +642 -184
  19. package/dist/infrastructure/index.js +21 -0
  20. package/dist/plugin/index.js +195 -19
  21. package/dist/security/index.js +52 -16
  22. package/dist/security/kanban-boundary.d.ts +3 -1
  23. package/dist/session-catalog/index.js +50 -15
  24. package/dist/session-catalog/project-server.js +50 -15
  25. package/dist/storage/cloud-config-sync/sanitize.d.ts +18 -0
  26. package/dist/storage/cloud-config-sync.d.ts +1 -1
  27. package/dist/storage/index.js +326 -71
  28. package/dist/storage/provider-config-watcher.d.ts +9 -0
  29. package/dist/storage/session-store/strict-empty-check.d.ts +7 -0
  30. package/dist/storage/session-store.d.ts +1 -0
  31. package/dist/tools/index.d.ts +1 -1
  32. package/dist/tools/index.js +83 -22
  33. package/dist/types/blocks.d.ts +9 -0
  34. package/dist/types/config/root.d.ts +14 -0
  35. package/dist/types/session.d.ts +7 -0
  36. package/instructions/agents/code-reviewer.md +3 -0
  37. package/instructions/coordination/subagent-baseline.md +10 -1
  38. package/instructions/system-lite.md +8 -5
  39. package/instructions/system-pro.md +26 -0
  40. package/instructions/system.md +21 -0
  41. package/package.json +3 -3
@@ -3456,6 +3456,10 @@ var IN_PROJECT_ALLOWED_KEYS = /* @__PURE__ */ new Set([
3456
3456
  "fallbackModels",
3457
3457
  "fallbackBridge",
3458
3458
  "fallbackProfiles",
3459
+ // The profile SELECTOR. No broader than its siblings: a repo that can write
3460
+ // `fallbackModels` and `fallbackProfiles` already controls the chain outright,
3461
+ // and this one can only name a profile the user already defined.
3462
+ "fallbackProfile",
3459
3463
  "favoriteModels",
3460
3464
  "favoriteModelsOnly",
3461
3465
  "modelAvailabilitySchedule",
@@ -3503,6 +3507,10 @@ var KNOWN_DENIED_IN_PROJECT = [
3503
3507
  {
3504
3508
  key: "git",
3505
3509
  reason: "Carries git.identity (GIT_AUTHOR_*/GIT_COMMITTER_* injection): a repo-committed config could spoof the author identity written into the victim's commit history (impersonation)."
3510
+ },
3511
+ {
3512
+ key: "fallbackMaxLastResortCandidates",
3513
+ reason: "Bounds how many of the user's OTHER configured providers may be swept in as last-resort failover. Setting it to 0 from a repo-committed config would silently strip that depth during an outage. It was already stripped in practice (absent from the allow-list) but was missing from the key registry, so this gate never checked it."
3506
3514
  }
3507
3515
  ];
3508
3516
  var KNOWN_CONFIG_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
@@ -3523,11 +3531,13 @@ var KNOWN_CONFIG_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
3523
3531
  "fallbackModels",
3524
3532
  "fallbackBridge",
3525
3533
  "fallbackProfiles",
3534
+ "fallbackProfile",
3526
3535
  "favoriteModels",
3527
3536
  "favoriteModelsOnly",
3528
3537
  "modelAvailabilitySchedule",
3529
3538
  "fallbackAuto",
3530
3539
  "fallbackStickiness",
3540
+ "fallbackMaxLastResortCandidates",
3531
3541
  "hooks",
3532
3542
  "plugins",
3533
3543
  "pluginManager",
@@ -3613,6 +3623,17 @@ var IN_PROJECT_DENIED_PATHS = [
3613
3623
  // operator owns, not the checked-out repository.
3614
3624
  path: "tools.kanbanGovernance",
3615
3625
  reason: "Repo-committed config could disable a Kanban governance gate the operator switched on, letting product mutations run outside any managed card."
3626
+ },
3627
+ {
3628
+ // The bridge spawn path resolves the CLI entry by walking UP from the
3629
+ // project root, so a repo that ships its own `packages/cli/dist/index.js`
3630
+ // gets that file spawned with `process.execPath` on WebUI boot — no
3631
+ // prompt, no banner. Turning the feature on is therefore equivalent to
3632
+ // arbitrary code execution for a hostile checkout, which makes this an
3633
+ // operator-owned switch and never a repo-owned one.
3634
+ // See discover-mailbox-bridge.ts:findWorkspaceCliEntry.
3635
+ path: "features.mailboxBridge",
3636
+ reason: "Enables the mailbox bridge, whose CLI-entry resolution walks up from the project root \u2014 a repo-supplied packages/cli/dist/index.js would be spawned on WebUI boot."
3616
3637
  }
3617
3638
  ];
3618
3639
  function deleteNestedPath(target, path13) {
@@ -2346,6 +2346,12 @@ function parseModelRef(ref) {
2346
2346
  function hasText(value) {
2347
2347
  return typeof value === "string" && value.trim().length > 0;
2348
2348
  }
2349
+ function asRefList(value) {
2350
+ return Array.isArray(value) ? value : void 0;
2351
+ }
2352
+ function asProfileName(value) {
2353
+ return hasText(value) ? value : void 0;
2354
+ }
2349
2355
  function providerHasKey(entry) {
2350
2356
  if (!entry) return false;
2351
2357
  if (hasText(entry.apiKey)) return true;
@@ -2356,7 +2362,7 @@ function providerHasKey(entry) {
2356
2362
  }
2357
2363
  function visibleProviderModels(config, providerId, providerModels) {
2358
2364
  const entry = config.providers?.[providerId];
2359
- return entry?.models !== void 0 ? [...entry.models] : providerModels;
2365
+ return Array.isArray(entry?.models) ? [...entry.models] : providerModels;
2360
2366
  }
2361
2367
  function buildProfiles(config) {
2362
2368
  const entries = /* @__PURE__ */ new Map();
@@ -2393,13 +2399,34 @@ var FallbackProfileManager = class {
2393
2399
  listProfiles() {
2394
2400
  return Object.freeze([...this.profiles.keys()]);
2395
2401
  }
2402
+ /**
2403
+ * The profile the session has selected (`config.fallbackProfile`, set by
2404
+ * `/fallback profile use <name>`), or undefined when none is selected or the
2405
+ * name no longer resolves to a defined profile.
2406
+ *
2407
+ * Consulted by every resolution entry point when the caller does not name a
2408
+ * profile itself. Without this the leader — which passes no profile — could
2409
+ * never use a named profile at all: `config.fallbackProfiles` was reachable
2410
+ * only by copying a chain into `fallbackModels`.
2411
+ */
2412
+ activeProfileName() {
2413
+ const name = asProfileName(this.config.fallbackProfile);
2414
+ return name && this.profiles.has(name) ? name : void 0;
2415
+ }
2396
2416
  // ── Resolution ─────────────────────────────────────────────────────────
2397
2417
  /**
2398
2418
  * Resolve a named fallback profile to a validated, provider-filtered chain.
2399
2419
  *
2400
- * Returns an empty chain when:
2401
- * - The profile doesn't exist.
2402
- * - Every entry's provider is missing, has no key, or has no matching model.
2420
+ * Returns an empty chain when the profile doesn't exist, or when every entry
2421
+ * is excluded, quarantined, or blacked out.
2422
+ *
2423
+ * Filtering is intentionally identical to {@link resolveRefs} (the explicit
2424
+ * `fallbackModels` path): the self-exclusion, the runtime status tracker,
2425
+ * and the availability calendar — nothing else. Anything a named profile
2426
+ * drops, an explicit chain drops too, and vice versa. Profiles used to apply
2427
+ * two extra filters (provider "usability" and the `providers[].models`
2428
+ * snapshot) that the explicit path did not, which silently rerouted roles
2429
+ * pinned to a profile onto a different model than the one configured.
2403
2430
  *
2404
2431
  * @param name - Profile name from config.fallbackProfiles.
2405
2432
  * @param defaultProvider - Used when an entry has no explicit provider.
@@ -2420,13 +2447,9 @@ var FallbackProfileManager = class {
2420
2447
  if (seen.has(key)) continue;
2421
2448
  seen.add(key);
2422
2449
  if (excludeKey && key === excludeKey) continue;
2423
- const health = this.checkProvider(providerId);
2424
- if (!health.usable) continue;
2425
2450
  if (this.statusTracker && !this.statusTracker.isAvailable(providerId, parsed.model)) continue;
2426
2451
  if (!evaluateModelCalendar(this.config.modelAvailabilitySchedule, providerId, parsed.model).allowed)
2427
2452
  continue;
2428
- const allowedModels = this.config.providers?.[providerId]?.models;
2429
- if (allowedModels && !allowedModels.includes(parsed.model)) continue;
2430
2453
  resolved.push({
2431
2454
  providerId,
2432
2455
  model: parsed.model,
@@ -2444,12 +2467,14 @@ var FallbackProfileManager = class {
2444
2467
  resolveEffective(opts = {}) {
2445
2468
  const bridge = this.resolveBridge(opts.exclude);
2446
2469
  let selected = FREEZER_EMPTY;
2447
- if (opts.fallbackModels && opts.fallbackModels.length > 0) {
2448
- const resolved = this.resolveRefs(opts.fallbackModels, opts.exclude);
2470
+ const explicitRefs = asRefList(opts.fallbackModels);
2471
+ const profileName = asProfileName(opts.fallbackProfile) ?? this.activeProfileName();
2472
+ if (explicitRefs && explicitRefs.length > 0) {
2473
+ const resolved = this.resolveRefs(explicitRefs, opts.exclude);
2449
2474
  if (resolved.length > 0) selected = resolved;
2450
2475
  }
2451
- if (selected.length === 0 && opts.fallbackProfile) {
2452
- const resolved = this.resolve(opts.fallbackProfile, { exclude: opts.exclude });
2476
+ if (selected.length === 0 && profileName) {
2477
+ const resolved = this.resolve(profileName, { exclude: opts.exclude });
2453
2478
  if (resolved.length > 0) selected = resolved;
2454
2479
  }
2455
2480
  if (selected.length === 0 && opts.fallbackAuto !== false) {
@@ -2525,13 +2550,14 @@ var FallbackProfileManager = class {
2525
2550
  };
2526
2551
  const configFallbackAuto = this.config.fallbackAuto;
2527
2552
  const effectiveFallbackAuto = configFallbackAuto !== void 0 && configFallbackAuto !== null ? configFallbackAuto : !opts.closedWorld;
2528
- const explicitRefs = opts.fallbackModels ?? this.config.fallbackModels;
2553
+ const explicitRefs = asRefList(opts.fallbackModels) ?? asRefList(this.config.fallbackModels);
2554
+ const profileName = asProfileName(opts.fallbackProfile) ?? this.activeProfileName();
2529
2555
  const explicitUsable = explicitRefs !== void 0 && explicitRefs.length > 0 && this.resolveRefs(explicitRefs, current).length > 0;
2530
- const profileUsable = opts.fallbackProfile !== void 0 && this.hasProfile(opts.fallbackProfile) && this.resolve(opts.fallbackProfile, { exclude: current }).length > 0;
2556
+ const profileUsable = profileName !== void 0 && this.hasProfile(profileName) && this.resolve(profileName, { exclude: current }).length > 0;
2531
2557
  const fromExplicitSource = explicitUsable || profileUsable;
2532
- const selectedChain = opts.closedWorld ? explicitRefs && explicitRefs.length > 0 ? this.resolveRefs(explicitRefs, current) : opts.fallbackProfile ? this.resolve(opts.fallbackProfile, { exclude: current }) : FREEZER_EMPTY : this.resolveEffective({
2558
+ const selectedChain = opts.closedWorld ? explicitRefs && explicitRefs.length > 0 ? this.resolveRefs(explicitRefs, current) : profileName ? this.resolve(profileName, { exclude: current }) : FREEZER_EMPTY : this.resolveEffective({
2533
2559
  fallbackModels: explicitRefs,
2534
- fallbackProfile: opts.fallbackProfile,
2560
+ fallbackProfile: profileName,
2535
2561
  fallbackAuto: effectiveFallbackAuto,
2536
2562
  exclude: current
2537
2563
  });
@@ -2548,7 +2574,7 @@ var FallbackProfileManager = class {
2548
2574
  });
2549
2575
  }
2550
2576
  candidates.push(...selectedChain);
2551
- if (!fromExplicitSource && effectiveFallbackAuto && opts.fallbackProfile !== "default") {
2577
+ if (!fromExplicitSource && effectiveFallbackAuto && profileName !== "default") {
2552
2578
  candidates.push(...this.resolve("default", { exclude: current }));
2553
2579
  }
2554
2580
  if (!fromExplicitSource && effectiveFallbackAuto) {
@@ -2626,7 +2652,7 @@ var FallbackProfileManager = class {
2626
2652
  const leaderModel = this.config.model;
2627
2653
  const providers = this.config.providers ?? {};
2628
2654
  const favoriteSet = new Set(
2629
- (this.config.favoriteModels ?? []).map((ref) => {
2655
+ (asRefList(this.config.favoriteModels) ?? []).map((ref) => {
2630
2656
  const p = parseModelRef(ref);
2631
2657
  return `${p.provider ?? leaderProvider}/${p.model}`;
2632
2658
  })
@@ -13703,6 +13729,156 @@ var CLOUD_SYNC_CONTRACT = {
13703
13729
  "extensions.plugins": EXTENSIONS_PLUGINS_TREE
13704
13730
  };
13705
13731
  var CLOUD_SYNC_NAMESPACES = Object.keys(CLOUD_SYNC_CONTRACT);
13732
+ var INBOUND_DENIED_PATHS = [
13733
+ // ── Code execution ──────────────────────────────────────────────────────
13734
+ {
13735
+ namespace: "mcp.servers",
13736
+ path: "mcpServers.*.command",
13737
+ reason: "Executable spawned for a stdio MCP server."
13738
+ },
13739
+ {
13740
+ namespace: "mcp.servers",
13741
+ path: "mcpServers.*.args",
13742
+ reason: "Argv for that executable."
13743
+ },
13744
+ {
13745
+ namespace: "mcp.servers",
13746
+ path: "mcpServers.*.transport",
13747
+ reason: "Switching transport to stdio selects the spawning code path."
13748
+ },
13749
+ {
13750
+ namespace: "extensions.plugins",
13751
+ path: "plugins",
13752
+ reason: "Plugin list is resolved and `await import`ed."
13753
+ },
13754
+ // `extensions` is deliberately NOT denied: it is per-plugin settings read via
13755
+ // `ConfigStore.getExtension(name)`, not a loader list, so it grants no import.
13756
+ // Syncing it is the feature (`extensions.telegram.notifyChatId` and friends).
13757
+ // Residual risk accepted: a plugin whose own settings include a URL can have
13758
+ // that URL rewritten by the portal. Constrain that in the plugin's
13759
+ // `configSchema`, which is where the loader validates it.
13760
+ // ── Credential redirection / exfiltration ───────────────────────────────
13761
+ {
13762
+ namespace: "providers.catalog",
13763
+ path: "providers.*.baseUrl",
13764
+ reason: "Repoints the provider endpoint; reinjectLocalSecrets keeps the local apiKey, so the real key follows the redirect."
13765
+ },
13766
+ {
13767
+ namespace: "providers.catalog",
13768
+ path: "providers.*.envVars",
13769
+ reason: "Chooses which environment variable is read for the key."
13770
+ },
13771
+ {
13772
+ namespace: "providers.catalog",
13773
+ path: "providers.*.activeKey",
13774
+ reason: "Selects which stored key is sent."
13775
+ },
13776
+ {
13777
+ namespace: "mcp.servers",
13778
+ path: "mcpServers.*.url",
13779
+ reason: "Remote MCP endpoint; receives whatever the transport carries."
13780
+ },
13781
+ {
13782
+ namespace: "mcp.servers",
13783
+ path: "mcpServers.*.envVars",
13784
+ reason: "Names of environment variables forwarded to the server process."
13785
+ },
13786
+ // ── Operator-owned safety switches ──────────────────────────────────────
13787
+ {
13788
+ namespace: "mcp.servers",
13789
+ path: "mcpServers.*.permission",
13790
+ reason: "Approval requirement for that server\u2019s tools."
13791
+ },
13792
+ { namespace: "core.runtime", path: "yolo", reason: "Disables every permission prompt." },
13793
+ {
13794
+ namespace: "core.runtime",
13795
+ path: "features.allowOutsideProjectRoot",
13796
+ reason: "Short-circuits project-root containment and the symlink realpath check."
13797
+ },
13798
+ {
13799
+ namespace: "core.runtime",
13800
+ path: "tools.restrictToProjectRoot",
13801
+ reason: "The other half of the filesystem confinement switch."
13802
+ },
13803
+ {
13804
+ namespace: "core.runtime",
13805
+ path: "features.developerMode",
13806
+ reason: "Loosens guardrails; an operator opt-in, not a synced preference."
13807
+ },
13808
+ {
13809
+ namespace: "core.runtime",
13810
+ path: "tools.disabledTools",
13811
+ reason: "Could re-enable a tool the operator deliberately switched off."
13812
+ },
13813
+ {
13814
+ namespace: "ui.preferences",
13815
+ path: "autonomy.defaultMode",
13816
+ reason: "Autonomy is user-owned, never remote-owned."
13817
+ },
13818
+ {
13819
+ namespace: "ui.preferences",
13820
+ path: "autonomy.yolo",
13821
+ reason: "Alias for the denied top-level `yolo`, and it wins over the user setting."
13822
+ },
13823
+ {
13824
+ namespace: "ui.preferences",
13825
+ path: "launch.autonomy",
13826
+ reason: "Launch-time autonomy mode; same user-owned boundary."
13827
+ },
13828
+ {
13829
+ namespace: "models.routing",
13830
+ path: "brain.mode",
13831
+ reason: "Selects the policy/LLM/human decision ladder."
13832
+ },
13833
+ {
13834
+ namespace: "models.routing",
13835
+ path: "brain.maxAutoRisk",
13836
+ reason: "The risk ceiling below which actions proceed without asking."
13837
+ }
13838
+ ];
13839
+ function contractHasPath(tree, segments) {
13840
+ if (segments.length === 0) return true;
13841
+ if (tree === true) return false;
13842
+ const [head, ...rest] = segments;
13843
+ if (head === void 0 || !Object.hasOwn(tree, head)) return false;
13844
+ const child = tree[head];
13845
+ return child === void 0 ? false : contractHasPath(child, rest);
13846
+ }
13847
+ function pruneContractPath(tree, segments) {
13848
+ if (tree === true || segments.length === 0) return tree;
13849
+ const [head, ...rest] = segments;
13850
+ if (head === void 0 || !Object.hasOwn(tree, head)) return tree;
13851
+ const next = { ...tree };
13852
+ if (rest.length === 0) {
13853
+ delete next[head];
13854
+ return next;
13855
+ }
13856
+ const child = next[head];
13857
+ if (child === void 0) return tree;
13858
+ next[head] = pruneContractPath(child, rest);
13859
+ return next;
13860
+ }
13861
+ function assertInboundDenyListResolves() {
13862
+ const unresolved = INBOUND_DENIED_PATHS.filter((entry) => {
13863
+ const tree = CLOUD_SYNC_CONTRACT[entry.namespace];
13864
+ return tree === void 0 || !contractHasPath(tree, entry.path.split("."));
13865
+ });
13866
+ if (unresolved.length > 0) {
13867
+ throw new Error(
13868
+ "INBOUND_DENIED_PATHS entr(ies) no longer resolve against CLOUD_SYNC_CONTRACT \u2014 a rename would silently re-open them: " + unresolved.map((entry) => `${entry.namespace}:${entry.path}`).join(", ")
13869
+ );
13870
+ }
13871
+ }
13872
+ var INBOUND_CONTRACT = (() => {
13873
+ assertInboundDenyListResolves();
13874
+ const out = { ...CLOUD_SYNC_CONTRACT };
13875
+ for (const entry of INBOUND_DENIED_PATHS) {
13876
+ const tree = out[entry.namespace];
13877
+ if (tree === void 0) continue;
13878
+ out[entry.namespace] = pruneContractPath(tree, entry.path.split("."));
13879
+ }
13880
+ return out;
13881
+ })();
13706
13882
  var NAMESPACE_SCHEMA_VERSIONS = {
13707
13883
  "core.runtime": 1,
13708
13884
  "ui.preferences": 1,
@@ -13794,7 +13970,7 @@ function mergeAtContract(local, incoming, tree) {
13794
13970
  return base;
13795
13971
  }
13796
13972
  function applyNamespacePayload(config, namespace, payload) {
13797
- const tree = CLOUD_SYNC_CONTRACT[namespace];
13973
+ const tree = INBOUND_CONTRACT[namespace];
13798
13974
  if (!tree || tree === true) return config;
13799
13975
  const next = { ...config };
13800
13976
  for (const [key, incoming] of Object.entries(payload)) {
@@ -894,6 +894,24 @@ function validateDirectoryPolicy(value) {
894
894
  import { homedir } from "node:os";
895
895
 
896
896
  // src/security/secret-scrubber.ts
897
+ var JSON_CREDENTIAL_KEY_ANCHORS = [
898
+ 'Key"',
899
+ 'key"',
900
+ 'KEY"',
901
+ 'token"',
902
+ 'Token"',
903
+ 'TOKEN"',
904
+ 'secret"',
905
+ 'Secret"',
906
+ 'SECRET"',
907
+ 'password"',
908
+ 'Password"',
909
+ 'PASSWORD"',
910
+ 'authorization"',
911
+ 'Authorization"',
912
+ 'bearer"',
913
+ 'Bearer"'
914
+ ];
897
915
  var PATTERNS = [
898
916
  // Anchored at the start where possible so partial matches inside larger
899
917
  // strings don't trigger false positives.
@@ -996,6 +1014,30 @@ var PATTERNS = [
996
1014
  regex: /(^|\s)([A-Z_]{4,}(?:KEY|TOKEN|SECRET|PASSWORD|PWD))\s*[:=]\s*['"]?([A-Za-z0-9_/+=-]{20,512})['"]?(?=\s|$)/g,
997
1015
  anchor: ["KEY", "TOKEN", "SECRET", "PASSWORD", "PWD"]
998
1016
  },
1017
+ {
1018
+ type: "json_credential_key",
1019
+ // The JSON counterpart to `high_entropy_env`, and the pattern that the
1020
+ // now-deleted `JSON_KEY_ANCHORS` list was written for. Without it those
1021
+ // anchors only widened the cheap pre-scan — `hasCredentialAnchors` said
1022
+ // "this text may hold a secret", every pattern then declined to match, and
1023
+ // the value went out verbatim. `high_entropy_env` cannot cover these: it
1024
+ // requires an UPPERCASE unquoted key (`API_KEY=…`), so `{"apiKey":"…"}`
1025
+ // never matched.
1026
+ //
1027
+ // Tool results are routinely serialised as JSON, and a credential with no
1028
+ // recognisable prefix (Azure, self-hosted gateways, Anthropic/Codex OAuth)
1029
+ // has no other pattern that can catch it — this is the only thing standing
1030
+ // between such a value and the session JSONL, chronicle, HQ broadcast and
1031
+ // the model's own context.
1032
+ //
1033
+ // The key may carry a prefix (`"anthropicApiKey"`), but the credential word
1034
+ // must END the key: `"tokenCount"` and `"maxTokens"` do not match, because
1035
+ // the closing quote has to follow the word immediately.
1036
+ // Value floor of 8 chars keeps enum-ish values (`"authorization":"none"`)
1037
+ // out. Capture groups: 1=key + punctuation, 2=value, 3=closing quote.
1038
+ regex: /("[A-Za-z0-9_]*(?:apiKey|api_key|token|secret|password|authorization|bearer|private_key|access_token|refresh_token|client_secret)"\s*:\s*")([^"\\]{8,512})(")/gi,
1039
+ anchor: JSON_CREDENTIAL_KEY_ANCHORS
1040
+ },
999
1041
  // ── Ported from packages/plugins credential-patterns.ts (WS-034) ─────────
1000
1042
  // The plugin runtime carried 37 patterns while this scrubber — the one that
1001
1043
  // guards session JSONL, chronicle, HQ broadcast, WebUI events and the auth
@@ -1078,9 +1120,12 @@ var PATTERNS = [
1078
1120
  anchor: "GOCSPX-"
1079
1121
  }
1080
1122
  ];
1081
- var SIMPLE_PATTERNS = PATTERNS.filter((p) => p.type !== "high_entropy_env");
1123
+ var SIMPLE_PATTERNS = PATTERNS.filter(
1124
+ (p) => p.type !== "high_entropy_env" && p.type !== "json_credential_key"
1125
+ );
1082
1126
  var COMBINED_REGEX = new RegExp(SIMPLE_PATTERNS.map((p) => `(${p.regex.source})`).join("|"), "g");
1083
1127
  var HIGH_ENTROPY_REGEX = PATTERNS.find((p) => p.type === "high_entropy_env").regex;
1128
+ var JSON_CREDENTIAL_REGEX = PATTERNS.find((p) => p.type === "json_credential_key").regex;
1084
1129
  var COMBINED_REPLACEMENTS = SIMPLE_PATTERNS.map((p) => `[REDACTED:${p.type}]`);
1085
1130
  var SCRUB_CHUNK_BYTES = 64 * 1024;
1086
1131
  var SCRUB_OVERLAP_BYTES = 1024;
@@ -1091,20 +1136,7 @@ var PATTERN_ANCHORS = [
1091
1136
  )
1092
1137
  )
1093
1138
  ];
1094
- var JSON_KEY_ANCHORS = [
1095
- '"apiKey"',
1096
- '"api_key"',
1097
- '"token"',
1098
- '"secret"',
1099
- '"password"',
1100
- '"authorization"',
1101
- '"bearer"',
1102
- '"private_key"',
1103
- '"access_token"',
1104
- '"refresh_token"',
1105
- '"client_secret"'
1106
- ];
1107
- var ALL_ANCHORS = [...PATTERN_ANCHORS, ...JSON_KEY_ANCHORS];
1139
+ var ALL_ANCHORS = PATTERN_ANCHORS;
1108
1140
  function hasCredentialAnchors(text) {
1109
1141
  for (const anchor of ALL_ANCHORS) {
1110
1142
  if (text.includes(anchor)) return true;
@@ -1153,6 +1185,9 @@ var DefaultSecretScrubber = class {
1153
1185
  out = out.replace(HIGH_ENTROPY_REGEX, (_match, lead, key, _value) => {
1154
1186
  return `${lead}${key}=[REDACTED:high_entropy_env]`;
1155
1187
  });
1188
+ out = out.replace(JSON_CREDENTIAL_REGEX, (_match, keyPrefix, _value, closingQuote) => {
1189
+ return `${keyPrefix}[REDACTED:json_credential_key]${closingQuote}`;
1190
+ });
1156
1191
  return out;
1157
1192
  }
1158
1193
  /**
@@ -1327,7 +1362,8 @@ async function evaluateToolKanbanBoundary(tool, input, ctx, options = {}) {
1327
1362
  decision: "block",
1328
1363
  reason: "Active card is not implementation-ready: " + readiness.issues.map((issue) => issue.message).join(" | "),
1329
1364
  boardId: board.id,
1330
- taskId: task.id
1365
+ taskId: task.id,
1366
+ readinessIssues: readiness.issues
1331
1367
  };
1332
1368
  }
1333
1369
  if (task.lifecycle?.currentStage !== "running" || task.assignment?.status !== "running") {
@@ -1,9 +1,11 @@
1
- import { type KanbanBoundaryEvaluation } from '@wrongstack/kanban';
1
+ import { type KanbanBoundaryEvaluation, type KanbanContractReadinessIssue } from '@wrongstack/kanban';
2
2
  import type { Context } from '../core/context.js';
3
3
  import type { Tool } from '../types/tool.js';
4
4
  export interface ToolKanbanBoundaryEvaluation extends KanbanBoundaryEvaluation {
5
5
  boardId?: string | undefined;
6
6
  taskId?: string | undefined;
7
+ /** Machine-readable readiness failures; `reason` remains the human fallback. */
8
+ readinessIssues?: KanbanContractReadinessIssue[] | undefined;
7
9
  }
8
10
  export interface ToolKanbanGovernanceOptions {
9
11
  /** Require every product mutation to run inside a ready, running Kanban card. */
@@ -777,6 +777,24 @@ function loadDatabaseSync() {
777
777
  }
778
778
 
779
779
  // src/security/secret-scrubber.ts
780
+ var JSON_CREDENTIAL_KEY_ANCHORS = [
781
+ 'Key"',
782
+ 'key"',
783
+ 'KEY"',
784
+ 'token"',
785
+ 'Token"',
786
+ 'TOKEN"',
787
+ 'secret"',
788
+ 'Secret"',
789
+ 'SECRET"',
790
+ 'password"',
791
+ 'Password"',
792
+ 'PASSWORD"',
793
+ 'authorization"',
794
+ 'Authorization"',
795
+ 'bearer"',
796
+ 'Bearer"'
797
+ ];
780
798
  var PATTERNS = [
781
799
  // Anchored at the start where possible so partial matches inside larger
782
800
  // strings don't trigger false positives.
@@ -879,6 +897,30 @@ var PATTERNS = [
879
897
  regex: /(^|\s)([A-Z_]{4,}(?:KEY|TOKEN|SECRET|PASSWORD|PWD))\s*[:=]\s*['"]?([A-Za-z0-9_/+=-]{20,512})['"]?(?=\s|$)/g,
880
898
  anchor: ["KEY", "TOKEN", "SECRET", "PASSWORD", "PWD"]
881
899
  },
900
+ {
901
+ type: "json_credential_key",
902
+ // The JSON counterpart to `high_entropy_env`, and the pattern that the
903
+ // now-deleted `JSON_KEY_ANCHORS` list was written for. Without it those
904
+ // anchors only widened the cheap pre-scan — `hasCredentialAnchors` said
905
+ // "this text may hold a secret", every pattern then declined to match, and
906
+ // the value went out verbatim. `high_entropy_env` cannot cover these: it
907
+ // requires an UPPERCASE unquoted key (`API_KEY=…`), so `{"apiKey":"…"}`
908
+ // never matched.
909
+ //
910
+ // Tool results are routinely serialised as JSON, and a credential with no
911
+ // recognisable prefix (Azure, self-hosted gateways, Anthropic/Codex OAuth)
912
+ // has no other pattern that can catch it — this is the only thing standing
913
+ // between such a value and the session JSONL, chronicle, HQ broadcast and
914
+ // the model's own context.
915
+ //
916
+ // The key may carry a prefix (`"anthropicApiKey"`), but the credential word
917
+ // must END the key: `"tokenCount"` and `"maxTokens"` do not match, because
918
+ // the closing quote has to follow the word immediately.
919
+ // Value floor of 8 chars keeps enum-ish values (`"authorization":"none"`)
920
+ // out. Capture groups: 1=key + punctuation, 2=value, 3=closing quote.
921
+ regex: /("[A-Za-z0-9_]*(?:apiKey|api_key|token|secret|password|authorization|bearer|private_key|access_token|refresh_token|client_secret)"\s*:\s*")([^"\\]{8,512})(")/gi,
922
+ anchor: JSON_CREDENTIAL_KEY_ANCHORS
923
+ },
882
924
  // ── Ported from packages/plugins credential-patterns.ts (WS-034) ─────────
883
925
  // The plugin runtime carried 37 patterns while this scrubber — the one that
884
926
  // guards session JSONL, chronicle, HQ broadcast, WebUI events and the auth
@@ -961,9 +1003,12 @@ var PATTERNS = [
961
1003
  anchor: "GOCSPX-"
962
1004
  }
963
1005
  ];
964
- var SIMPLE_PATTERNS = PATTERNS.filter((p) => p.type !== "high_entropy_env");
1006
+ var SIMPLE_PATTERNS = PATTERNS.filter(
1007
+ (p) => p.type !== "high_entropy_env" && p.type !== "json_credential_key"
1008
+ );
965
1009
  var COMBINED_REGEX = new RegExp(SIMPLE_PATTERNS.map((p) => `(${p.regex.source})`).join("|"), "g");
966
1010
  var HIGH_ENTROPY_REGEX = PATTERNS.find((p) => p.type === "high_entropy_env").regex;
1011
+ var JSON_CREDENTIAL_REGEX = PATTERNS.find((p) => p.type === "json_credential_key").regex;
967
1012
  var COMBINED_REPLACEMENTS = SIMPLE_PATTERNS.map((p) => `[REDACTED:${p.type}]`);
968
1013
  var SCRUB_CHUNK_BYTES = 64 * 1024;
969
1014
  var SCRUB_OVERLAP_BYTES = 1024;
@@ -974,20 +1019,7 @@ var PATTERN_ANCHORS = [
974
1019
  )
975
1020
  )
976
1021
  ];
977
- var JSON_KEY_ANCHORS = [
978
- '"apiKey"',
979
- '"api_key"',
980
- '"token"',
981
- '"secret"',
982
- '"password"',
983
- '"authorization"',
984
- '"bearer"',
985
- '"private_key"',
986
- '"access_token"',
987
- '"refresh_token"',
988
- '"client_secret"'
989
- ];
990
- var ALL_ANCHORS = [...PATTERN_ANCHORS, ...JSON_KEY_ANCHORS];
1022
+ var ALL_ANCHORS = PATTERN_ANCHORS;
991
1023
  function hasCredentialAnchors(text) {
992
1024
  for (const anchor of ALL_ANCHORS) {
993
1025
  if (text.includes(anchor)) return true;
@@ -1036,6 +1068,9 @@ var DefaultSecretScrubber = class {
1036
1068
  out = out.replace(HIGH_ENTROPY_REGEX, (_match, lead, key, _value) => {
1037
1069
  return `${lead}${key}=[REDACTED:high_entropy_env]`;
1038
1070
  });
1071
+ out = out.replace(JSON_CREDENTIAL_REGEX, (_match, keyPrefix, _value, closingQuote) => {
1072
+ return `${keyPrefix}[REDACTED:json_credential_key]${closingQuote}`;
1073
+ });
1039
1074
  return out;
1040
1075
  }
1041
1076
  /**
@@ -221,6 +221,24 @@ function loadDatabaseSync() {
221
221
  }
222
222
 
223
223
  // src/security/secret-scrubber.ts
224
+ var JSON_CREDENTIAL_KEY_ANCHORS = [
225
+ 'Key"',
226
+ 'key"',
227
+ 'KEY"',
228
+ 'token"',
229
+ 'Token"',
230
+ 'TOKEN"',
231
+ 'secret"',
232
+ 'Secret"',
233
+ 'SECRET"',
234
+ 'password"',
235
+ 'Password"',
236
+ 'PASSWORD"',
237
+ 'authorization"',
238
+ 'Authorization"',
239
+ 'bearer"',
240
+ 'Bearer"'
241
+ ];
224
242
  var PATTERNS = [
225
243
  // Anchored at the start where possible so partial matches inside larger
226
244
  // strings don't trigger false positives.
@@ -323,6 +341,30 @@ var PATTERNS = [
323
341
  regex: /(^|\s)([A-Z_]{4,}(?:KEY|TOKEN|SECRET|PASSWORD|PWD))\s*[:=]\s*['"]?([A-Za-z0-9_/+=-]{20,512})['"]?(?=\s|$)/g,
324
342
  anchor: ["KEY", "TOKEN", "SECRET", "PASSWORD", "PWD"]
325
343
  },
344
+ {
345
+ type: "json_credential_key",
346
+ // The JSON counterpart to `high_entropy_env`, and the pattern that the
347
+ // now-deleted `JSON_KEY_ANCHORS` list was written for. Without it those
348
+ // anchors only widened the cheap pre-scan — `hasCredentialAnchors` said
349
+ // "this text may hold a secret", every pattern then declined to match, and
350
+ // the value went out verbatim. `high_entropy_env` cannot cover these: it
351
+ // requires an UPPERCASE unquoted key (`API_KEY=…`), so `{"apiKey":"…"}`
352
+ // never matched.
353
+ //
354
+ // Tool results are routinely serialised as JSON, and a credential with no
355
+ // recognisable prefix (Azure, self-hosted gateways, Anthropic/Codex OAuth)
356
+ // has no other pattern that can catch it — this is the only thing standing
357
+ // between such a value and the session JSONL, chronicle, HQ broadcast and
358
+ // the model's own context.
359
+ //
360
+ // The key may carry a prefix (`"anthropicApiKey"`), but the credential word
361
+ // must END the key: `"tokenCount"` and `"maxTokens"` do not match, because
362
+ // the closing quote has to follow the word immediately.
363
+ // Value floor of 8 chars keeps enum-ish values (`"authorization":"none"`)
364
+ // out. Capture groups: 1=key + punctuation, 2=value, 3=closing quote.
365
+ regex: /("[A-Za-z0-9_]*(?:apiKey|api_key|token|secret|password|authorization|bearer|private_key|access_token|refresh_token|client_secret)"\s*:\s*")([^"\\]{8,512})(")/gi,
366
+ anchor: JSON_CREDENTIAL_KEY_ANCHORS
367
+ },
326
368
  // ── Ported from packages/plugins credential-patterns.ts (WS-034) ─────────
327
369
  // The plugin runtime carried 37 patterns while this scrubber — the one that
328
370
  // guards session JSONL, chronicle, HQ broadcast, WebUI events and the auth
@@ -405,9 +447,12 @@ var PATTERNS = [
405
447
  anchor: "GOCSPX-"
406
448
  }
407
449
  ];
408
- var SIMPLE_PATTERNS = PATTERNS.filter((p) => p.type !== "high_entropy_env");
450
+ var SIMPLE_PATTERNS = PATTERNS.filter(
451
+ (p) => p.type !== "high_entropy_env" && p.type !== "json_credential_key"
452
+ );
409
453
  var COMBINED_REGEX = new RegExp(SIMPLE_PATTERNS.map((p) => `(${p.regex.source})`).join("|"), "g");
410
454
  var HIGH_ENTROPY_REGEX = PATTERNS.find((p) => p.type === "high_entropy_env").regex;
455
+ var JSON_CREDENTIAL_REGEX = PATTERNS.find((p) => p.type === "json_credential_key").regex;
411
456
  var COMBINED_REPLACEMENTS = SIMPLE_PATTERNS.map((p) => `[REDACTED:${p.type}]`);
412
457
  var SCRUB_CHUNK_BYTES = 64 * 1024;
413
458
  var SCRUB_OVERLAP_BYTES = 1024;
@@ -418,20 +463,7 @@ var PATTERN_ANCHORS = [
418
463
  )
419
464
  )
420
465
  ];
421
- var JSON_KEY_ANCHORS = [
422
- '"apiKey"',
423
- '"api_key"',
424
- '"token"',
425
- '"secret"',
426
- '"password"',
427
- '"authorization"',
428
- '"bearer"',
429
- '"private_key"',
430
- '"access_token"',
431
- '"refresh_token"',
432
- '"client_secret"'
433
- ];
434
- var ALL_ANCHORS = [...PATTERN_ANCHORS, ...JSON_KEY_ANCHORS];
466
+ var ALL_ANCHORS = PATTERN_ANCHORS;
435
467
  function hasCredentialAnchors(text) {
436
468
  for (const anchor of ALL_ANCHORS) {
437
469
  if (text.includes(anchor)) return true;
@@ -480,6 +512,9 @@ var DefaultSecretScrubber = class {
480
512
  out = out.replace(HIGH_ENTROPY_REGEX, (_match, lead, key, _value) => {
481
513
  return `${lead}${key}=[REDACTED:high_entropy_env]`;
482
514
  });
515
+ out = out.replace(JSON_CREDENTIAL_REGEX, (_match, keyPrefix, _value, closingQuote) => {
516
+ return `${keyPrefix}[REDACTED:json_credential_key]${closingQuote}`;
517
+ });
483
518
  return out;
484
519
  }
485
520
  /**