@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +18 -5
  3. package/SECURITY.md +25 -8
  4. package/bin/fabric-agent-adapter.js +24 -1
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +16 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +79 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +86 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  69. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  70. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  71. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  72. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  79. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  86. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
  90. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  91. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
@@ -22,10 +22,10 @@ import fabric_secrets as _secrets # noqa: E402 - the contract's credential dete
22
22
 
23
23
  CONTRACT_VERSION = "0.1.0"
24
24
  CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
25
- CONTRACT_COMMIT = "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
25
+ CONTRACT_COMMIT = "93e7db164c7829e2783b4b7705c4113bb5201131"
26
26
  # #region supported-contract-revisions — docs: README.md#contract-pin
27
27
  # Only this declaration may name a legacy revision in live files.
28
- SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "eac1a594aa131851f079da8bb7b2149e1951f35f", "3830a3353923e20f826d466610e1c479f120bec0", "00e050ba7429c0c60261cec871779e0fd6aeeb28", "1197a90dc4b57152cabcc71ad17db7559f35a27f", "e6c4fc16b415eec3190dd34d924d269405359dc9", "a3a66eb0052043847f6556348e37d34ca873a629", "52c7d798b9f4eb784eb4e29e717038a8912c759d", "2f020a696453ae299b123517f14957f6a1354379", "87d384ffce2757081b7ab227e5dad3dddd08328f", "0a4ce773c4b9e6bec40c6387eb3f988af7152c0e", "e742016dc396aa31e5a13a893489e43f15786c69", "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0", "c55f2d5a11c9a0197e958e87212fca7ae1154769", "07042181bfae2a481aa0da1a5651f24c76669c48", "d8146cfb0a1c74fc6432284c4356bb0c864b37b5", "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
28
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "18f8d48762edc5bfb1433657cbfe1c16196df1a6", "eac1a594aa131851f079da8bb7b2149e1951f35f", "3830a3353923e20f826d466610e1c479f120bec0", "00e050ba7429c0c60261cec871779e0fd6aeeb28", "1197a90dc4b57152cabcc71ad17db7559f35a27f", "e6c4fc16b415eec3190dd34d924d269405359dc9", "a3a66eb0052043847f6556348e37d34ca873a629", "52c7d798b9f4eb784eb4e29e717038a8912c759d", "2f020a696453ae299b123517f14957f6a1354379", "87d384ffce2757081b7ab227e5dad3dddd08328f", "0a4ce773c4b9e6bec40c6387eb3f988af7152c0e", "e742016dc396aa31e5a13a893489e43f15786c69", "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0", "c55f2d5a11c9a0197e958e87212fca7ae1154769", "07042181bfae2a481aa0da1a5651f24c76669c48", "d8146cfb0a1c74fc6432284c4356bb0c864b37b5", "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
29
29
  # #endregion supported-contract-revisions
30
30
  INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
31
31
  MCP_REVISION = "2026-07-28"
@@ -488,6 +488,98 @@ def scaffold_project(root: Path, args: argparse.Namespace) -> Dict[str, Any]:
488
488
  }
489
489
 
490
490
 
491
+ # #region source-manifest: fabric.source.json (contract DEC-0149, fabric-source/1; FAA-261)
492
+ SOURCE_FILE = "fabric.source.json"
493
+ _SLOT_NAME = re.compile(r"[A-Z][A-Z0-9_]{0,63}\Z")
494
+ #: Instance data never named in include: the repository, credentials by their name, dependency and build folders.
495
+ _NOT_SOURCE = re.compile(r"\.git|\.env(\..*)?|.*\.(pem|key|p12|pfx|jks|keystore|token|credential)|node_modules|\.venv|venv|"
496
+ r"__pycache__|dist|build|\.DS_Store|" + re.escape(SOURCE_FILE), re.IGNORECASE)
497
+ #: The slots the kit names for every agent: the provider's createdBy and, for a service, its supervisor label and port
498
+ #: are instance data (FAA-188 part 2); the placement record is the operator's infrastructure, read at start.
499
+ PUBLISHER_SLOT = {"name": "PUBLISHER_URI", "kind": "config",
500
+ "purpose": "provider.createdBy: the publisher's URI; the source keeps the scaffold's sentinel"}
501
+ PLACEMENT_SLOT = {"name": "PLACEMENT_RECORD", "kind": "endpoint", "optional": True,
502
+ "purpose": "Project Observatory's placement record (observatory-placement/1), read at start"}
503
+ SERVICE_SLOTS = (
504
+ {"name": "SUPERVISOR_LABEL", "kind": "config",
505
+ "purpose": "the launchd label, systemd unit or Scheduled Task this installation runs under (LC-09)"},
506
+ {"name": "SERVICE_PORT", "kind": "config", "purpose": "the loopback port recorded for this OS account (DEC-0154 HA-1)"},
507
+ )
508
+
509
+
510
+ def _parse_slot(text: str, kind: str) -> Dict[str, Any]:
511
+ parts = text.split(":", 2 if kind == "store" else 1)
512
+ if len(parts) != (3 if kind == "store" else 2) or not _SLOT_NAME.match(parts[0]) or not parts[-1].strip():
513
+ raise AdaptationError("--%s takes %s" % (kind, "NAME:PATH:PURPOSE" if kind == "store" else "NAME:PURPOSE"))
514
+ slot = {"name": parts[0], "kind": kind, "purpose": parts[-1].strip()}
515
+ if kind == "store":
516
+ slot["path"] = parts[1]
517
+ if _secrets.secret_value(slot["purpose"]):
518
+ raise AdaptationError("a slot is named, never filled: --%s %s holds a value shaped like a credential" % (kind, parts[0]))
519
+ return slot
520
+
521
+
522
+ def source_manifest(include: Sequence[str], description: Optional[str] = None, secrets: Sequence[str] = (),
523
+ stores: Sequence[str] = (), service: bool = False, provider: bool = True) -> Dict[str, Any]:
524
+ """A fabric-source/1 manifest: include as given (sorted, default deny), the kit's slots, the slots named."""
525
+ manifest: Dict[str, Any] = {"format": "fabric-source/1"}
526
+ if description:
527
+ manifest["description"] = description
528
+ manifest["include"] = sorted(dict.fromkeys(include))
529
+ slots = [_parse_slot(t, "secret") for t in secrets] + [_parse_slot(t, "store") for t in stores]
530
+ slots += ([dict(PUBLISHER_SLOT)] if provider else []) + [dict(PLACEMENT_SLOT)]
531
+ slots += [dict(s) for s in SERVICE_SLOTS] if service else []
532
+ names = [s["name"] for s in slots]
533
+ if len(set(names)) != len(names):
534
+ raise AdaptationError("a slot is named twice")
535
+ manifest["slots"] = slots
536
+ return manifest
537
+
538
+
539
+ def _top_level(root: Path) -> List[str]:
540
+ """The project's top-level entries as include paths: what Git would commit, else what a walk finds; a folder with
541
+ a trailing /. Instance data by its name is left out."""
542
+ names: List[str] = []
543
+ try:
544
+ listed = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
545
+ capture_output=True, timeout=60)
546
+ if listed.returncode == 0:
547
+ names = [os.fsdecode(n) for n in listed.stdout.split(b"\0") if n]
548
+ except (OSError, subprocess.SubprocessError):
549
+ names = []
550
+ if not names:
551
+ names = [_relative(path, root) for path in _files(root)]
552
+ tops = []
553
+ for name in names:
554
+ head, _, rest = name.partition("/")
555
+ if not _NOT_SOURCE.fullmatch(head):
556
+ tops.append(head + "/" if rest else head)
557
+ return sorted(dict.fromkeys(tops))
558
+
559
+
560
+ def write_source_manifest(root: Path, args: argparse.Namespace) -> Dict[str, Any]:
561
+ """`source`: lay down fabric.source.json from the project's top-level entries. An existing one is the owner's and
562
+ is kept unless --force; --dry-run writes nothing."""
563
+ if not root.is_dir():
564
+ raise AdaptationError("project path is not a directory: %s" % root)
565
+ provider = (root / "fabric-agent.json").is_file()
566
+ manifest = source_manifest(_top_level(root), args.description, args.secret, args.store, args.service, provider)
567
+ target = root / SOURCE_FILE
568
+ exists = os.path.lexists(str(target))
569
+ result = {"project": str(root.resolve()), "manifest": manifest, "exists": exists}
570
+ if args.dry_run:
571
+ result["dryRun"] = True
572
+ return result
573
+ if exists and not args.force:
574
+ raise AdaptationError("%s exists: it is the owner's; --dry-run shows the proposal, --force replaces it" % SOURCE_FILE)
575
+ target.write_text(_json_text(manifest), encoding="utf-8")
576
+ result["written"] = SOURCE_FILE
577
+ result["next"] = ("Review include (default deny), name every key and store as a slot, then run "
578
+ "fabric_source_check.py check on the project (exit 0).")
579
+ return result
580
+ # #endregion
581
+
582
+
491
583
  def _load_json(path: Path, errors: List[str]) -> Optional[Any]:
492
584
  try:
493
585
  return json.loads(path.read_text(encoding="utf-8"))
@@ -567,6 +659,13 @@ import {SCHEMA_PREFIX} from './src/contract.ts';
567
659
  return "PASS", ["manifest validated by selected contract schema at %s" % selected_commit]
568
660
 
569
661
 
662
+ def _stored_url_credential(url: str) -> bool:
663
+ """A credential in a URL a manifest or plugin.json STORES (LC-21): secret_url's reading with the host not checked,
664
+ as repository_credential reads a repository. secret_url reads a URL whose host `new URL` refuses as an argument only
665
+ (FAA-309), right for a reader, but storage is the closed side: a password stays a password whatever its host."""
666
+ return _secrets._url_credential(url, hosts=False)
667
+
668
+
570
669
  def _credential_problems(manifest: Dict[str, Any]) -> List[str]:
571
670
  """LC-21 (FAA-23): no credential in a launch argument or in a URL the manifest names."""
572
671
  problems: List[str] = []
@@ -578,7 +677,7 @@ def _credential_problems(manifest: Dict[str, Any]) -> List[str]:
578
677
  if isinstance(arg, str) and _secrets.secret_argument(arg):
579
678
  problems.append("LC-21: capability[%d] passes a credential in a launch argument; argv is visible to every process" % index)
580
679
  for field in ("url", "agentCardUrl"):
581
- if isinstance(connection.get(field), str) and _secrets.secret_url(connection[field]):
680
+ if isinstance(connection.get(field), str) and _stored_url_credential(connection[field]):
582
681
  problems.append("LC-21: capability[%d].%s carries a credential in the URL; a query lands in every log" % (index, field))
583
682
  return problems
584
683
 
@@ -620,7 +719,7 @@ def _plugin_problems(root: Path) -> List[str]:
620
719
  if "command" in entry:
621
720
  problems.append("LC-19: plugin %s declares the stdio MCP server %s, which starts in every session that enables the plugin; serve it over HTTP from one resident process or register it at project scope" % (name, server))
622
721
  argv = [entry.get("command")] + list(entry.get("args") or [])
623
- if any(isinstance(a, str) and _secrets.secret_argument(a) for a in argv) or (isinstance(entry.get("url"), str) and _secrets.secret_url(entry["url"])):
722
+ if any(isinstance(a, str) and _secrets.secret_argument(a) for a in argv) or (isinstance(entry.get("url"), str) and _stored_url_credential(entry["url"])):
624
723
  problems.append("LC-21: plugin %s's MCP server %s carries a credential in its argv or URL" % (name, server))
625
724
  env = entry.get("env") if isinstance(entry.get("env"), dict) else {}
626
725
  if any(isinstance(v, str) and _secret_env_value(v) for v in env.values()): # path-aware for a path (FAA-266)
@@ -834,6 +933,17 @@ def build_parser() -> argparse.ArgumentParser:
834
933
  scaffold.add_argument("--dry-run", action="store_true", help="print the planned writes and a unified diff of each existing path; write nothing")
835
934
  scaffold.add_argument("--json", action="store_true")
836
935
 
936
+ source = sub.add_parser("source", help="lay down fabric.source.json, the agent's source manifest (DEC-0149)")
937
+ source.add_argument("project", type=Path)
938
+ source.add_argument("--description", help="what the agent is, in plain words")
939
+ source.add_argument("--secret", action="append", default=[], metavar="NAME:PURPOSE", help="a key the agent reads by name; repeat")
940
+ source.add_argument("--store", action="append", default=[], metavar="NAME:PATH:PURPOSE",
941
+ help="a data store relative to the data folder (it becomes the package's state[]); repeat")
942
+ source.add_argument("--service", action="store_true", help="a kit service: its supervisor label and port are slots")
943
+ source.add_argument("--force", action="store_true", help="replace an existing fabric.source.json")
944
+ source.add_argument("--dry-run", action="store_true", help="print the proposal; write nothing")
945
+ source.add_argument("--json", action="store_true")
946
+
837
947
  check = sub.add_parser("check", help="check generated structure and optional pinned schema")
838
948
  check.add_argument("project", type=Path)
839
949
  check.add_argument("--contract", type=Path)
@@ -848,6 +958,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
848
958
  result = inspect_project(args.project)
849
959
  elif args.command == "scaffold":
850
960
  result = scaffold_project(args.project, args)
961
+ elif args.command == "source":
962
+ result = write_source_manifest(args.project, args)
851
963
  else:
852
964
  result = check_project(args.project, args.contract)
853
965
  if result.get("dryRun") and not args.json:
@@ -28,8 +28,32 @@ CREDENTIAL_PAIRS = {
28
28
  'session': {'token', 'id'},
29
29
  'code': {'login', 'auth', 'access', 'session'}}
30
30
  JWT_SHAPE = re.compile(r'eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*')
31
- SECRET_VALUE = re.compile(r'(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}'
32
- r'|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})', re.I | re.S)
31
+ URL_BASE64_RUN = re.compile(r'[A-Za-z0-9_-]*')
32
+
33
+
34
+ def has_jwt(text):
35
+ # Whether JWT_SHAPE matches anywhere, read in one pass (the contract's hasJwt, FAA-313): the expression backtracks
36
+ # from every `eyJ` of a long run, quadratic in it, while every `eyJ` of one run shares that run's end, so only its
37
+ # first is tried. The kit's fabric_secrets.has_jwt reads the same way; this file stands alone, so it has its own.
38
+ at = text.find('eyJ')
39
+ while at >= 0:
40
+ one = URL_BASE64_RUN.match(text, at + 3).end()
41
+ if one - at - 3 >= 2 and text[one:one + 1] == '.':
42
+ two = URL_BASE64_RUN.match(text, one + 1).end()
43
+ if two - one - 1 >= 2 and text[two:two + 1] == '.':
44
+ return True
45
+ at = text.find('eyJ', one)
46
+ return False
47
+
48
+
49
+ # The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key with its
50
+ # prefix in its canonical case after a boundary (the start, a character that is no ASCII letter or digit, or an escape);
51
+ # an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more.
52
+ SECRET_VALUE = re.compile(r'(^[Bb][Ee][Aa][Rr][Ee][Rr]\s.+|(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt"\']|\\u[0-9A-Fa-f]{4}'
53
+ r'|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m'
54
+ r'|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)'
55
+ r'(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})'
56
+ r'|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}))', re.S)
33
57
  LONG_RUN = re.compile(r'[A-Za-z0-9+/=_-]{40,}')
34
58
 
35
59
 
@@ -52,7 +76,7 @@ def credential_name(name):
52
76
  def secret_value(value):
53
77
  long_secret = any(re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) and re.search(r'[0-9]', run)
54
78
  for run in LONG_RUN.findall(value))
55
- return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or long_secret)
79
+ return bool(has_jwt(value) or SECRET_VALUE.search(value) or long_secret)
56
80
 
57
81
 
58
82
  def carries_credential(text):
@@ -115,6 +139,11 @@ def check(data):
115
139
  require(all(isinstance(v, str) and 0 < len(v) <= 200 for v in (target, viewer)), 'missing_device')
116
140
  fallback = data.get('fallback', 'if_absent')
117
141
  require(fallback in ('if_absent', 'never'), 'invalid_fallback')
142
+ # DEC-0154 PL-3 and HA-2 (FAA-251): who listens on http_url's port, as the host read it from the socket table
143
+ # (fabric_peer_owner's kinds, or check_service.py's foreign-user). A browser opens a raw loopback address only
144
+ # after that listener is this OS account's; absent, it is unknown.
145
+ owner = data.get('listener_owner', 'unknown')
146
+ require(owner in ('this-account', 'another-account', 'system', 'unknown', 'none', 'foreign-user'), 'invalid_listener_owner')
118
147
  kind = action.get('kind')
119
148
  if kind == 'remote_open':
120
149
  require(set(action) <= {'kind', 'device_id', 'service', 'path'}, 'unexpected_action_field')
@@ -128,6 +157,7 @@ def check(data):
128
157
  require(set(action) == {'kind', 'url'} and action.get('url') == http, 'not_resolved_http_url')
129
158
  require(viewer == target, 'wrong_viewer_device')
130
159
  require(state == 'not_installed' and fallback == 'if_absent', 'browser_fallback_forbidden')
160
+ require(owner == 'this-account', 'listener_not_this_account')
131
161
  else:
132
162
  raise ValueError('unsupported_dashboard_action')
133
163
  return {'ok': True, 'kind': kind}
@@ -10,8 +10,11 @@ U+001C to U+001F, and not U+FEFF), `.` stopping at every line terminator, ASCII
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import bisect
14
+ import ipaddress
13
15
  import re
14
- from typing import Dict, List, Tuple
16
+ from typing import Dict, List, Optional, Sequence, Tuple
17
+ import unicodedata
15
18
  import urllib.parse
16
19
 
17
20
  CREDENTIAL_WORDS = (
@@ -30,8 +33,48 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
30
33
  JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
31
34
  _NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
32
35
  JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
33
- SECRET_VALUE = re.compile(r"(^bearer[%s]%s+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}"
34
- r"|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})" % (JS_SPACE, _NOT_LINE_END), re.IGNORECASE | re.ASCII)
36
+ _URL_BASE64_RUN = re.compile(r"[A-Za-z0-9_-]*")
37
+
38
+
39
+ def jwt_spans(text: str, first: bool = False) -> List[Tuple[int, int]]:
40
+ """The spans JWT_SHAPE matches in a text, leftmost and not overlapping, read in one pass (the contract's `jwtSpans`,
41
+ FAA-313): the regular expression backtracks from every `eyJ` of a long run, quadratic in it, while every `eyJ` of
42
+ one run shares that run's end, so only its first is tried, and a match ends where its third segment's run ends.
43
+ `first` stops at the first match."""
44
+ spans: List[Tuple[int, int]] = []
45
+ at = text.find("eyJ")
46
+ while at >= 0:
47
+ one = _URL_BASE64_RUN.match(text, at + 3).end()
48
+ resume = one
49
+ if one - at - 3 >= 2 and text[one:one + 1] == ".":
50
+ two = _URL_BASE64_RUN.match(text, one + 1).end()
51
+ if two - one - 1 >= 2 and text[two:two + 1] == ".":
52
+ three = _URL_BASE64_RUN.match(text, two + 1).end()
53
+ spans.append((at, three))
54
+ if first:
55
+ break
56
+ resume = three
57
+ at = text.find("eyJ", resume)
58
+ return spans
59
+
60
+
61
+ def has_jwt(text: str) -> bool:
62
+ """Whether JWT_SHAPE matches anywhere in a text, read in one pass (the contract's `hasJwt`, FAA-313)."""
63
+ return bool(jwt_spans(text, True))
64
+ #: Where a provider key's prefix may start (DEC-0151): the start of a value, a character that is no ASCII letter or
65
+ #: digit, or an escape (a percent-escape, single or double, a backslash escape, an octal, `\\x` with two hex digits,
66
+ #: `\\u` with four, an ESC colour sequence, raw or escaped). A consuming group, as the contract writes it.
67
+ _KEY_BOUNDARY = (r"(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt\"']|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}"
68
+ r"|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)")
69
+ #: The provider keys' shapes, prefixes in their canonical case only (DEC-0151 item 7: `SK-` or `GHP_` is no key). An
70
+ #: `sk-` key holds, within 256 characters, a digit and an upper-case letter with 16 or more characters, or a digit with
71
+ #: 32 or more; the look-aheads are bounded to 255 characters and lazy, so a long run of prefixes reads in linear time.
72
+ _PROVIDER_KEYS = (r"(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})"
73
+ r"|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})")
74
+ #: The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key after a
75
+ #: boundary. No case-insensitive flag; digits are ASCII. A match includes its boundary character: callers test, never
76
+ #: read the matched text or its index.
77
+ SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + _NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
35
78
  _LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
36
79
  _BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
37
80
  re.IGNORECASE | re.ASCII)
@@ -71,7 +114,7 @@ def _long_secret(value: str) -> bool:
71
114
 
72
115
  def secret_value(value: str) -> bool:
73
116
  """A JWT anywhere, a provider key or `Bearer …`, or a long base64 run mixing upper case, lower case and digits."""
74
- return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _long_secret(value))
117
+ return bool(has_jwt(value) or SECRET_VALUE.search(value) or _long_secret(value))
75
118
 
76
119
 
77
120
  def _path_like(piece: str) -> bool:
@@ -83,7 +126,7 @@ def _path_like(piece: str) -> bool:
83
126
  def secret_argument(value: str) -> bool:
84
127
  """The same for a launch argument or a server's command and URL: a long run is looked for between path separators,
85
128
  so a folder path holding a session id is not taken for a key."""
86
- if JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
129
+ if has_jwt(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
87
130
  return True
88
131
  for piece in _ARGUMENT_SPLIT.split(value):
89
132
  if _path_like(piece):
@@ -129,6 +172,155 @@ _C0_OR_SPACE = "".join(map(chr, range(0x21)))
129
172
  _LONE_SURROGATE = re.compile("[\ud800-\udfff]")
130
173
 
131
174
 
175
+ # The WHATWG host parser, enough to know when `new URL` refuses a host (FAA-98, moved here from fabric_connections for
176
+ # secret_url in FAA-309, so the module still ships alone in adapting-projects-to-fabric).
177
+ _FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
178
+ _FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
179
+
180
+
181
+ # FAA-98: UTS46 narrowed, not ported. A non-ASCII host is refused only where every Node from 20 to 26 refuses it, so
182
+ # the doubt stays on the closed side (its URL's credentials are read). The two tables are measured by
183
+ # test/fixtures/idna/make_idna_tables.mjs (idna-tables.json, held equal by test_the_idna_tables_are_the_measured_ones):
184
+ # code points refused in every context, and code points removed between letters. The contextual rules read Unicode 3.2
185
+ # classes (`unicodedata.ucd_3_2_0`) and skip what that Unicode does not know, since Node's own UTS46 data lags its
186
+ # Unicode: an Arabic letter of Unicode 14 is no right-to-left letter to it.
187
+ _IDNA_DISALLOWED = (
188
+ (0x80, 0xa0), (0xa8, 0xa8), (0xaf, 0xaf), (0xb4, 0xb4), (0xb8, 0xb8), (0x2d8, 0x2dd), (0x37a, 0x37a),
189
+ (0x384, 0x385), (0x600, 0x605), (0x61c, 0x61c), (0x6dd, 0x6dd), (0x70f, 0x70f), (0x890, 0x891), (0x8e2, 0x8e2),
190
+ (0x1680, 0x1680), (0x1fbd, 0x1fbd), (0x1fbf, 0x1fc1), (0x1fcd, 0x1fcf), (0x1fdd, 0x1fdf), (0x1fed, 0x1fee),
191
+ (0x1ffd, 0x1ffe), (0x2000, 0x200a), (0x200e, 0x200f), (0x2017, 0x2017), (0x2024, 0x2026), (0x2028, 0x202f),
192
+ (0x203e, 0x203e), (0x2047, 0x2049), (0x205f, 0x205f), (0x2066, 0x2069), (0x2100, 0x2101), (0x2105, 0x2106),
193
+ (0x2488, 0x249b), (0x2a74, 0x2a74), (0x2ff0, 0x3000), (0x309b, 0x309c), (0x31ef, 0x31ef), (0x33c2, 0x33c2),
194
+ (0x33c7, 0x33c7), (0x33d8, 0x33d8), (0xe000, 0xf8ff), (0xfc5e, 0xfc63), (0xfdfa, 0xfdfb), (0xfe12, 0xfe13),
195
+ (0xfe16, 0xfe16), (0xfe19, 0xfe19), (0xfe30, 0xfe30), (0xfe47, 0xfe4c), (0xfe52, 0xfe52), (0xfe55, 0xfe56),
196
+ (0xfe5f, 0xfe5f), (0xfe64, 0xfe65), (0xfe68, 0xfe68), (0xfe6a, 0xfe6b), (0xfe70, 0xfe70), (0xfe72, 0xfe72),
197
+ (0xfe74, 0xfe74), (0xfe76, 0xfe76), (0xfe78, 0xfe78), (0xfe7a, 0xfe7a), (0xfe7c, 0xfe7c), (0xfe7e, 0xfe7e),
198
+ (0xff03, 0xff03), (0xff05, 0xff05), (0xff0f, 0xff0f), (0xff1a, 0xff1a), (0xff1c, 0xff1c), (0xff1e, 0xff20),
199
+ (0xff3b, 0xff3e), (0xff5c, 0xff5c), (0xffe3, 0xffe3), (0xfff9, 0xfffd), (0x110bd, 0x110bd), (0x110cd, 0x110cd),
200
+ (0x13430, 0x1343f), (0x1f100, 0x1f100), (0xe0001, 0xe0001), (0xe0020, 0xe007f), (0xf0000, 0xffffd),
201
+ (0x100000, 0x10fffd),
202
+ )
203
+ _IDNA_IGNORED = (
204
+ (0xad, 0xad), (0x34f, 0x34f), (0x180b, 0x180d), (0x180f, 0x180f), (0x200b, 0x200b), (0x2060, 0x2060),
205
+ (0x2064, 0x2064), (0xfe00, 0xfe0f), (0xfeff, 0xfeff), (0x1bca0, 0x1bca3), (0xe0100, 0xe01ef),
206
+ )
207
+ _IDNA_DOTS = re.compile("[.\u3002\uff0e\uff61]")
208
+ _U32 = unicodedata.ucd_3_2_0
209
+ _RTL_OK = {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}
210
+ _JOINERS = "\u200c\u200d"
211
+
212
+
213
+ def _in_ranges(ranges: Sequence[tuple], cp: int) -> bool:
214
+ i = bisect.bisect_right(ranges, (cp, 0x10ffff)) - 1
215
+ return i >= 0 and ranges[i][0] <= cp <= ranges[i][1]
216
+
217
+
218
+ def _known(c: str) -> bool:
219
+ return _U32.category(c) != "Cn" and unicodedata.category(c) != "Cn"
220
+
221
+
222
+ def _joiner_refused(label: str, i: int) -> bool:
223
+ """CheckJoiners as Node 20 to 26 all read it: they find a joining context, or a virama, anywhere in the label
224
+ (`\u0628a\u200c\u0627` and `\u0628\u200c\u0627\u200d` parse), so only a joiner with nothing but ASCII beside it
225
+ in its label has none for certain."""
226
+ return all(c.isascii() for j, c in enumerate(label) if j != i)
227
+
228
+
229
+ def _bidi_refused(label: str) -> bool:
230
+ """RFC 5893 as Node 20 to 26 all read it: only a label holding R, AL or AN is judged, and its first code point is
231
+ not (Node 26 alone refuses a label opening with a digit or a hyphen)."""
232
+ if not label or not all(_known(c) for c in label) or any(c in _JOINERS for c in label):
233
+ return False # Node 20 to 26 pass a label holding a joiner over (`\u05d0a\u094d\u200d` parses)
234
+ k = [_U32.bidirectional(c) for c in label]
235
+ if not any(x in ("R", "AL", "AN") for x in k):
236
+ return False
237
+ body = list(k)
238
+ while body and body[-1] == "NSM":
239
+ body.pop()
240
+ if not body or body[-1] not in ("R", "AL", "EN", "AN"):
241
+ return True
242
+ return k[0] in ("R", "AL") and (any(x not in _RTL_OK for x in k) or ("EN" in k and "AN" in k))
243
+
244
+
245
+ def _idna_host(host: str) -> Optional[str]:
246
+ """None where WHATWG's domain-to-ASCII refuses a non-ASCII host for certain; else the host as UTS46 maps it, read
247
+ as NFKC with case folding (exact enough for the ASCII rules that follow, which read digits and dots)."""
248
+ if any(_in_ranges(_IDNA_DISALLOWED, ord(c)) for c in host):
249
+ return None
250
+ kept = "".join(c for c in host if not _in_ranges(_IDNA_IGNORED, ord(c)))
251
+ if kept == "":
252
+ return None # nothing left: an empty host
253
+ if not all(_known(c) for c in kept):
254
+ return host # a code point this Python's Unicode does not know: the contextual rules are not read
255
+ mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", kept).casefold()))
256
+ for label in _IDNA_DOTS.split(mapped):
257
+ first = label[:1]
258
+ if first and first not in "\u17b4\u17b5" and _U32.category(first)[0] == "M" and unicodedata.category(first)[0] == "M":
259
+ return None # a label opening with a mark (U+17B4 and U+17B5 are ignored by the newer Nodes)
260
+ if any(c in _JOINERS and _joiner_refused(label, i) for i, c in enumerate(label)):
261
+ return None
262
+ if _bidi_refused(label):
263
+ return None
264
+ return _IDNA_DOTS.sub(".", mapped)
265
+
266
+
267
+ def _ipv4_number(part: str) -> Optional[int]:
268
+ if part == "":
269
+ return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
270
+ base = 10
271
+ if part[:2].lower() == "0x":
272
+ part, base = part[2:], 16
273
+ elif len(part) > 1 and part[0] == "0":
274
+ part, base = part[1:], 8
275
+ if part == "":
276
+ return 0
277
+ digits = {10: "0123456789", 16: "0123456789abcdefABCDEF", 8: "01234567"}[base]
278
+ return int(part, base) if all(c in digits for c in part) else None
279
+
280
+
281
+ def _domain_ok(host: str) -> bool:
282
+ """The WHATWG host parser on a special scheme's host, enough to know when `new URL` refuses it."""
283
+ if host.startswith("["):
284
+ return host.endswith("]") and _ipv6_ok(host[1:-1])
285
+ if re.search(r"%(?![0-9A-Fa-f]{2})", host) is None:
286
+ try:
287
+ host = urllib.parse.unquote_to_bytes(host).decode("utf-8")
288
+ except UnicodeDecodeError:
289
+ return False
290
+ if any(c in _FORBIDDEN_DOMAIN for c in host):
291
+ return False
292
+ # A non-ASCII host goes through UTS46 in WHATWG; this module reads it narrowly (_idna_host, FAA-98). When unsure,
293
+ # the host is taken as valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
294
+ # (round-2 reading). A host UTS46 maps to ASCII is read by the ASCII rules below (`127.0.0.1` is an address).
295
+ if any(ord(c) > 0x7f for c in host):
296
+ mapped = _idna_host(host)
297
+ if mapped is None:
298
+ return False
299
+ if not mapped.isascii() or any(c in _FORBIDDEN_DOMAIN for c in mapped):
300
+ return True
301
+ host = mapped
302
+ labels = host.split(".")
303
+ if labels and labels[-1] == "" and len(labels) > 1:
304
+ labels = labels[:-1]
305
+ last = labels[-1] if labels else ""
306
+ if not (last and (last.isdigit() and last.isascii() or re.fullmatch(r"0[xX][0-9A-Fa-f]*", last))):
307
+ return host != ""
308
+ numbers = [_ipv4_number(label) for label in labels]
309
+ if len(numbers) > 4 or any(n is None for n in numbers):
310
+ return False
311
+ return all(n <= 255 for n in numbers[:-1]) and numbers[-1] < 256 ** (5 - len(numbers))
312
+
313
+
314
+ def _ipv6_ok(text: str) -> bool:
315
+ if "%" in text:
316
+ return False # a zone id: `ipaddress` takes one, WHATWG does not
317
+ try:
318
+ ipaddress.IPv6Address(text)
319
+ return True
320
+ except ValueError:
321
+ return False
322
+
323
+
132
324
  def _percent_encode(text: str, also: str) -> str:
133
325
  """WHATWG's percent-encoding of a query or a fragment: every C0 control, space, `"`, `<`, `>`, every code point
134
326
  above U+007E and the characters in `also`, as UTF-8 %XX (a lone surrogate as U+FFFD, as a USVString reads it).
@@ -150,8 +342,8 @@ def url_parts(value: str) -> Tuple[str, str, str]:
150
342
  after every slash or backslash that follows `scheme:` (the special authority ignore slashes state) and ends at the
151
343
  next slash, backslash, `?` or `#`, so `<https>:u:p@h` and `<https>:\\u:p@h` carry the password p; any other scheme
152
344
  has an authority only after `//`, and `file` has no user information. The user information ends at the authority's
153
- last `@`, and the password follows its first `:`. A host `new URL` would refuse is not checked: such a URL is read
154
- for its password and parameters all the same, the safe direction for a detector."""
345
+ last `@`, and the password follows its first `:`. The host is not checked here: secret_url refuses first a URL
346
+ whose host or port `new URL` refuses (url_refused, FAA-309)."""
155
347
  scheme, _, rest = value.partition(":")
156
348
  rest, _, fragment = rest.partition("#")
157
349
  rest, _, query = rest.partition("?")
@@ -170,10 +362,60 @@ def url_parts(value: str) -> Tuple[str, str, str]:
170
362
  _url_parts = url_parts # the name before FAA-250, kept for code that reached it
171
363
 
172
364
 
365
+ def url_refused(value: str) -> bool:
366
+ """Whether `new URL` refuses an absolute URL (trimmed as secret_url trims it) for its host or port (FAA-309), read
367
+ with the same authority url_parts reads: a special scheme's host through the WHATWG host parser and the IDNA reading
368
+ of FAA-98 (`_domain_ok`), never empty; `file`'s host, after `//`, the same, empty allowed; another scheme's host,
369
+ after `//`, without a forbidden host code point, and empty only with no user information and no `:` after it (an
370
+ empty port is refused too); a port of ASCII digits up to 65535. Where the reading is unsure, the host is taken as
371
+ valid (the detector's safe side)."""
372
+ value = _LONE_SURROGATE.sub("\ufffd", value)
373
+ scheme, _, rest = value.partition(":")
374
+ rest = rest.partition("#")[0].partition("?")[0]
375
+ lowered = scheme.lower()
376
+ if lowered in _SPECIAL:
377
+ authority = rest.replace("\\", "/").lstrip("/").partition("/")[0]
378
+ elif lowered == "file":
379
+ rest = rest.replace("\\", "/")
380
+ if not rest.startswith("//"):
381
+ return False
382
+ host = rest[2:].partition("/")[0]
383
+ return not (host == "" or re.fullmatch(r"[A-Za-z][:|]", host) or _domain_ok(host))
384
+ elif rest.startswith("//"):
385
+ authority = rest[2:].partition("/")[0]
386
+ else:
387
+ return False
388
+ hostport = authority.rpartition("@")[2]
389
+ if hostport.startswith("["):
390
+ close = hostport.find("]")
391
+ if close < 0:
392
+ return True
393
+ host, after = hostport[:close + 1], hostport[close + 1:]
394
+ if after and not after.startswith(":"):
395
+ return True
396
+ port = after[1:]
397
+ else:
398
+ host, _, port = hostport.partition(":")
399
+ if port and not (port.isascii() and port.isdigit() and int(port) <= 65535):
400
+ return True
401
+ if lowered in _SPECIAL:
402
+ return host == "" or not _domain_ok(host)
403
+ if host.startswith("["):
404
+ return not _ipv6_ok(host[1:-1])
405
+ return any(c in _FORBIDDEN_HOST for c in host) or (host == "" and ("@" in authority or ":" in hostport))
406
+
407
+
173
408
  def secret_url(value: str) -> bool:
174
409
  """Whether a URL an MCP entry or a launch names carries a credential (LC-21): anything secret_argument finds, a
175
410
  password in its user information, or a query or fragment parameter whose name can only carry a credential and has
176
- a value, or whose value is shaped like one. A string that is not an absolute URL is checked as an argument only."""
411
+ a value, or whose value is shaped like one. A string that is not an absolute URL, or one whose host or port
412
+ `new URL` refuses (url_refused, FAA-309), is checked as an argument only, as the Node twin's secretUrl reads it."""
413
+ return _url_credential(value, hosts=True)
414
+
415
+
416
+ def _url_credential(value: str, hosts: bool) -> bool:
417
+ """secret_url; with `hosts` False the host is not checked, as repository_credential reads a repository's parameters
418
+ whatever its host (the Node twin's repositoryParts)."""
177
419
  if secret_argument(value):
178
420
  return True
179
421
  # What WHATWG URL parsing does first: trim C0 controls and space from the ends (never other Unicode spaces, which it
@@ -181,6 +423,8 @@ def secret_url(value: str) -> bool:
181
423
  value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
182
424
  if not _SCHEME.match(value):
183
425
  return False # not an absolute URL (WHATWG URL parsing refuses it)
426
+ if hosts and url_refused(value):
427
+ return False # `new URL` refuses its host or port, so the Node twin reads it as an argument only (FAA-309)
184
428
  password, query, fragment = url_parts(value)
185
429
  if password:
186
430
  return True
@@ -194,7 +438,10 @@ def secret_url(value: str) -> bool:
194
438
  _REPOSITORY_URI = re.compile(r"\A([A-Za-z][A-Za-z0-9+.-]*:(?://[^/?#]*)?)([^?#]*)(.*)\Z", re.S)
195
439
  _REPOSITORY_NAME = re.compile(r"(?:[a-z0-9._-]+|[A-Za-z._-]+)\Z") # lower case, or letters with no digit (readings 8)
196
440
  _REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
197
- _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % SECRET_VALUE.pattern, re.IGNORECASE | re.ASCII)
441
+ #: A repository name's own key shapes, boundary-free and case-sensitive (DEC-0151): SECRET_VALUE takes a consuming
442
+ #: boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
443
+ _REPOSITORY_KEYS = r"sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}"
444
+ _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % _REPOSITORY_KEYS)
198
445
  _JWT_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))%s" % JWT_SHAPE.pattern)
199
446
 
200
447
 
@@ -245,4 +492,4 @@ def repository_credential(value: str) -> bool:
245
492
  odd = [piece for written in (path, ascii_decoded(path)) for piece in re.split(r"[/\\:]", written)
246
493
  if piece and not repository_name(piece)]
247
494
  # the scheme and authority decoded too: git sends `https://%67hp_...@h` with its user decoded (unit reading 12)
248
- return secret_url(head + tail) or secret_argument(ascii_decoded(head)) or any(secret_argument(piece) for piece in odd)
495
+ return _url_credential(head + tail, hosts=False) or secret_argument(ascii_decoded(head)) or any(secret_argument(piece) for piece in odd)