@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.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 (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  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 +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -83,8 +83,10 @@ or instructions alone do not prove all final answers from every agent are filter
83
83
  path is required. Skills must be activated/loaded; install does not intercept every answer.
84
84
  - MCP absent: report unresolved host capability once, use the project's approved
85
85
  resolver/CLI if available. Do not write machine or agent MCP config automatically.
86
- - Python absent: perform the comparisons above manually; mark the executable gate
87
- NOT_RUN. Do not label the output mechanically validated.
86
+ - Python absent: perform the comparisons above manually, including the gate's own rules (a
87
+ path with a `.` or `..` segment, an encoded or backslash separator, or a query or fragment
88
+ name or value that reads as a credential, such as `code`, `key`, `auth` or `api_key`, is
89
+ refused); mark the executable gate NOT_RUN. Do not label the output mechanically validated.
88
90
 
89
91
  Syntax is owned by Fabric Dashboards and `@passioncode-ai/fabric-service-host`:
90
92
  [ADR-0005](https://github.com/passioncode-ai/fabric-dashboards/blob/51a7a80acf42781d7bec304cc87c655d48cc309b/docs/adr/0005-service-links.md).
@@ -0,0 +1,55 @@
1
+ # The operator channel and the repository standard
2
+
3
+ Shared by `adapting-projects-to-fabric` and `creating-fabric-agents`; the two copies are byte-identical
4
+ (`test/test_shared_references.py`). "The agent" is the provider being adapted or created.
5
+
6
+ ## The operator channel
7
+
8
+ If the agent has a human stop (a choice, an approval, a confirmation) or emits `notify: true`
9
+ events, propose its **operator channel** to the operator once, at the step that links here. The
10
+ channel is the agent's own messenger channel, first Telegram, that sends those events and answers
11
+ its stops with buttons: `fabric-operator-channel/0.1` (contract DEC-0034,
12
+ `docs/specification/operator-channel.md`), whose rule OC-11 this follows. Give the operator's four
13
+ steps:
14
+
15
+ 1. create a bot with @BotFather;
16
+ 2. put its token into the operator's secret store (Project Observatory where it is installed) under
17
+ the name the agent reads (for example `EXAMPLE_AGENT_TELEGRAM_BOT_TOKEN`), never into a chat, a
18
+ file or the bundle;
19
+ 3. run the agent's link command;
20
+ 4. type the code it shows in the chat with the bot.
21
+
22
+ In a group shared with other bots the channel follows OC-14 to OC-17 (DEC-0035): it stays silent
23
+ unless addressed (`/command@ThisBot`, a reply to its own message or a press of its own button; a
24
+ bare `/command` in a group is not addressed), and a channel nobody receives on is `degraded`
25
+ (`receiver-missing`). Say so in the proposal when the operator means to bind a shared group.
26
+
27
+ Record the answer (`accepted`, `declined` or `later`) in the agent's state and in its conformance
28
+ report, and do not ask again; an agent that serves `surfaces.operatorChannel` also shows it as
29
+ `proposal` in its status document. Creating the bot, storing the token and enabling the channel
30
+ are the operator's action: never create the bot, ask for or store the token's value, or enable the
31
+ channel yourself. The default pin carries DEC-0034 and DEC-0035: a served status document is
32
+ checked against the pinned `schemas/operator-channel-status.schema.json`; the channel's runtime
33
+ rules have no probe rule here yet, so report them `NOT_RUN`. An agent with neither stops nor notify
34
+ events skips this.
35
+
36
+ ## The repository standard
37
+
38
+ For a PassionCode.ai repository, the agent's repository follows the organization's
39
+ [repository standard](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/repository-standard.md)
40
+ (`knowledge/repository-standard.md` in the organization's knowledge base; a clone has it at
41
+ `fabric-workspace/knowledge/`) from its first commit:
42
+
43
+ - `LICENSE`, `COMMERCIAL-LICENSE.md` and `CLA.md` copied byte for byte from the knowledge base's
44
+ `templates/`, and `AGPL-3.0-only OR LicenseRef-PassionCode-Commercial` in every manifest and
45
+ every SKILL.md `license:`; `SECURITY.md` when the repository is public;
46
+ - a README whose first heading is the full name, with `## Quick start for a new teammate`
47
+ (Install; Configure, key names only; MCP, the registration command and one proving tool call,
48
+ verified with a real client in a temporary config; Develop) and `## License`;
49
+ - an `AGENTS.md` that opens with the template's *Read first* block and closes with *After work*,
50
+ and a `CLAUDE.md` whose first line is `@AGENTS.md`.
51
+
52
+ org-index `scripts/check_format.py` reports 0 findings for it before it is called done. An agent
53
+ someone builds for themselves is not a PassionCode.ai repository: its licence is its owner's
54
+ choice, nothing about it is published or listed by the organization, and none of these files is
55
+ required of it, though the verified MCP quick start is still how anyone learns to drive it.
@@ -32,7 +32,9 @@ python3 scripts/fabric_provider.py validate ~/Library/Application\ Support/ai.pa
32
32
  python3 scripts/fabric_provider.py remove example-agent # the uninstaller
33
33
  ```
34
34
 
35
- `--stdio` takes the rest of the line as the argument array; there is no shell string. An entry may carry
35
+ `--stdio` comes last and takes the rest of the line as the argument array; there is no shell string. A writer
36
+ option after it (`--env`, `--name`, ...) is refused rather than swallowed into argv; write `--stdio -- EXECUTABLE ...`
37
+ when the server itself takes an option of that name. `--env` belongs to a stdio server and is refused with `--url`. An entry may carry
36
38
  `run.mcp.stdio.scope` (DEC-0044): `project` (started for the sessions of a project that binds it) or `on-demand`;
37
39
  `session` (a server in every coding session) is refused (FAC-SEM-056, LC-19). The validator accepts the field; the
38
40
  writer does not set it until Fabric reads it. An argument that carries a credential is refused
@@ -46,8 +48,12 @@ It refuses, with one sentence that never quotes a value:
46
48
  - an env value that is not `secret-ref:<name>`, or a reference that has the shape of a
47
49
  credential itself: a token prefix, a private key, a JWT (FAC-SEM-015: a value is
48
50
  checked by its form; name patterns apply to names);
49
- - a URL other than `http://127.0.0.1:<port>/mcp`, a manifest path that is not an
50
- absolute or `~/` path to `fabric-agent.json`, a shell-string command, unknown fields.
51
+ - what the pinned `provider.schema.json` refuses: a URL other than `http://127.0.0.1:<port>/mcp` with a port from 100
52
+ to 65535, a manifest path that is not an absolute or `~/` path to `fabric-agent.json` (or is a network share, or over
53
+ 1024 characters), a shell-string command, more than 32 arguments or one over 1024 characters, more than 64 env
54
+ names, a `source.repository` that is not an absolute URI, an `installedAt` that is not an RFC 3339 date-time,
55
+ unknown fields. The patterns are the schema's, copied (`test_provider.SchemaParityTests` compares them with a
56
+ contract checkout at the pin when `FABRIC_CONTRACT_NEW` names one).
51
57
 
52
58
  `validate` also checks that the file is named `<id>.json` (FAC-SEM-014, slug half).
53
59
  From Python: `write_provider_entry(entry)`, `remove_provider_entry(id)`,
@@ -18,6 +18,21 @@ Use only `PASS`, `FAIL`, `NOT_RUN`, and `NOT_VERIFIED`:
18
18
  - `NOT_VERIFIED`: no implemented verifier/runtime currently exists for the claim.
19
19
  - Never relabel either as a warning-level pass.
20
20
 
21
+ ## Template sentinels
22
+
23
+ `check` lists every value the scaffold invented as a warning, and `readyForAdmission` stays false
24
+ while one remains: `placeholder:` for a fixed sentinel (the zero content hash, `replace-me`,
25
+ `.invalid`, `urn:fabric:adapter:author`, the generic probe assertion, a generated file still marked
26
+ `fabric-adapter-template` or still in the generic request/summary shape, the generic fixture) and
27
+ `template-default:` for each JSON pointer listed under the manifest's `urn:fabric:adapter:template`
28
+ extension (a legal value the author did not choose, such as `dataClasses: ["public"]`). A warning
29
+ is cleared by replacing the value with an implementation-backed fact and removing the pointer from
30
+ that list; deleting the block without confirming its values is a false receipt.
31
+
32
+ `check --contract` runs the checkout's own `node_modules/.bin/tsx`, never `pnpm exec`, so it cannot
33
+ reinstall or purge the contract checkout. A manifest that is not UTF-8 or not JSON is an
34
+ `invalid-json:` receipt, not a crash.
35
+
21
36
  ## Receipts
22
37
 
23
38
  A receipt names what ran and what identity was tested:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import datetime as dt
8
+ import difflib
8
9
  import json
9
10
  import math
10
11
  import os
@@ -43,6 +44,22 @@ GENERATED_PATHS = (
43
44
  )
44
45
 
45
46
 
47
+ # #region template-sentinels
48
+ # Every value the scaffold invents is a sentinel `check` finds: a fixed string, a `$comment` or Markdown mark, or a
49
+ # JSON pointer listed under TEMPLATE_KEY in the manifest's extensions (a value that is legal but was not chosen by the
50
+ # author, such as dataClasses ["public"]). readyForAdmission stays false while one remains.
51
+ TEMPLATE_KEY = "urn:fabric:adapter:template"
52
+ TEMPLATE_MARK = "fabric-adapter-template"
53
+ AUTHOR_SENTINEL = "urn:fabric:adapter:author"
54
+ WEAK_ASSERTION = "returns a typed result without external effects"
55
+ FIXTURE_SENTINEL = "Return one bounded, non-publishing test result."
56
+ ZERO_HASH = "sha256:" + ("0" * 64)
57
+ PLACEHOLDER_TEXT = ("replace-me", ".invalid")
58
+ DATA_CLASSES = ("public", "project-internal", "confidential", "personal", "credential", "regulated")
59
+ IDENTITY_METHODS = ("tls", "oauth2", "signed-manifest", "local-install")
60
+ # #endregion template-sentinels
61
+
62
+
46
63
  class AdaptationError(Exception):
47
64
  """Expected user-facing adaptation error."""
48
65
 
@@ -84,7 +101,7 @@ def inspect_project(root: Path) -> Dict[str, Any]:
84
101
  if not root.is_dir():
85
102
  raise AdaptationError("project path is not a directory: %s" % root)
86
103
 
87
- evidence: Dict[str, List[str]] = {"mcp": [], "a2a": [], "local-runner": [], "autonomous": []}
104
+ evidence: Dict[str, List[str]] = {"mcp": [], "a2a": [], "local-runner": [], "autonomous": [], "mcp-client-config": []}
88
105
  sampled = 0
89
106
  file_count = 0
90
107
  for path in _files(root):
@@ -93,7 +110,11 @@ def inspect_project(root: Path) -> Dict[str, Any]:
93
110
  lower_rel = rel.lower()
94
111
  if lower_rel.endswith("agent-card.json") or ".well-known/agent-card" in lower_rel:
95
112
  evidence["a2a"].append("file:%s" % rel)
96
- if "mcp" in Path(lower_rel).name:
113
+ client_config = _is_mcp_client_config(path)
114
+ if client_config:
115
+ # A client's server list (.mcp.json, mcpServers) says what the project CALLS, not what it serves.
116
+ evidence["mcp-client-config"].append("file:%s" % rel)
117
+ elif "mcp" in Path(lower_rel).name:
97
118
  evidence["mcp"].append("file:%s" % rel)
98
119
  if lower_rel.startswith("bin/") or lower_rel in {"cli.py", "cli.js", "cli.ts"}:
99
120
  evidence["local-runner"].append("file:%s" % rel)
@@ -105,7 +126,7 @@ def inspect_project(root: Path) -> Dict[str, Any]:
105
126
  continue
106
127
  sampled += 1
107
128
  lowered = text.lower()
108
- if "@modelcontextprotocol/sdk" in lowered or "fastmcp" in lowered or "model context protocol" in lowered:
129
+ if not client_config and ("@modelcontextprotocol/sdk" in lowered or "fastmcp" in lowered or "model context protocol" in lowered):
109
130
  evidence["mcp"].append("content:%s" % rel)
110
131
  if '"a2a"' in lowered or "agent card" in lowered or "/.well-known/agent-card.json" in lowered:
111
132
  evidence["a2a"].append("content:%s" % rel)
@@ -136,6 +157,9 @@ def inspect_project(root: Path) -> Dict[str, Any]:
136
157
  else:
137
158
  recommendation = "undetermined"
138
159
  question = "Which capability is being adapted, and who owns its task lifecycle?"
160
+ if evidence["mcp-client-config"] and "mcp" not in candidates:
161
+ question = ("The project configures MCP clients (%s); that lists servers it calls, not one it serves. "
162
+ "Does it also serve an MCP server Fabric can call? %s" % (", ".join(evidence["mcp-client-config"][:3]), question or ""))
139
163
 
140
164
  return {
141
165
  "project": str(root.resolve()),
@@ -149,6 +173,15 @@ def inspect_project(root: Path) -> Dict[str, Any]:
149
173
  }
150
174
 
151
175
 
176
+ def _is_mcp_client_config(path: Path) -> bool:
177
+ if path.name.lower() in (".mcp.json", "claude_desktop_config.json"):
178
+ return True
179
+ if path.suffix.lower() not in (".json", ".toml", ".yaml", ".yml"):
180
+ return False
181
+ text = _read_text(path).lower()
182
+ return "mcpservers" in text or "mcp_servers" in text
183
+
184
+
152
185
  def _require_absolute_uri(value: str, label: str) -> None:
153
186
  parsed = urlparse(value)
154
187
  if not parsed.scheme:
@@ -171,7 +204,7 @@ def _profile(args: argparse.Namespace, schema_base: str) -> Dict[str, Any]:
171
204
  "outputSchema": output_uri,
172
205
  "timeoutMs": 30000,
173
206
  "sideEffectCeiling": "none",
174
- "assertions": ["returns a typed result without external effects"],
207
+ "assertions": list(getattr(args, "probe_assertion", None) or [WEAK_ASSERTION]),
175
208
  }
176
209
  if profile == "mcp":
177
210
  if args.mcp_mode == "stdio":
@@ -245,6 +278,29 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
245
278
  if _secrets.secret_argument(runtime_arg):
246
279
  raise AdaptationError("a runtime argument carries a credential; argv is visible to every process (LC-21): read it from a secret reference instead")
247
280
 
281
+ defaulted: List[str] = []
282
+ created_by = getattr(args, "created_by", None)
283
+ if created_by:
284
+ _require_absolute_uri(created_by, "created-by")
285
+ else:
286
+ created_by = AUTHOR_SENTINEL
287
+ defaulted.append("/provider/createdBy")
288
+ local = args.profile == "local-runner" or (args.profile == "mcp" and args.mcp_mode == "stdio")
289
+ identity_method = getattr(args, "identity_method", None)
290
+ if not identity_method:
291
+ identity_method = "local-install" if local else "tls"
292
+ if not local:
293
+ defaulted.append("/provider/identity/method")
294
+ elif identity_method not in IDENTITY_METHODS:
295
+ raise AdaptationError("unknown identity method %r (%s)" % (identity_method, ", ".join(IDENTITY_METHODS)))
296
+ data_classes = list(dict.fromkeys(getattr(args, "data_class", None) or []))
297
+ if any(c not in DATA_CLASSES for c in data_classes):
298
+ raise AdaptationError("unknown data class (%s)" % ", ".join(DATA_CLASSES))
299
+ if not data_classes:
300
+ data_classes = ["public"]
301
+ defaulted.append("/capabilities/0/dataClasses")
302
+ if not getattr(args, "probe_assertion", None):
303
+ defaulted.append("/capabilities/0/profile/probes/0/assertions")
248
304
  created_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
249
305
  # DEC-0076: a writer emits identifiers in their normal form, so every reader finds them under one spelling.
250
306
  provider_id = _normal_identifier(args.provider_id)
@@ -256,11 +312,11 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
256
312
  "revision": 1,
257
313
  "contentHash": "sha256:" + ("0" * 64),
258
314
  "createdAt": created_at,
259
- "createdBy": "urn:fabric:adapter:author",
315
+ "createdBy": created_by,
260
316
  "name": args.provider_name,
261
317
  "identity": {
262
318
  "subject": provider_id,
263
- "method": "local-install" if args.profile == "local-runner" or (args.profile == "mcp" and args.mcp_mode == "stdio") else "tls",
319
+ "method": identity_method,
264
320
  },
265
321
  "supportedContractVersions": [CONTRACT_VERSION],
266
322
  },
@@ -272,7 +328,7 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
272
328
  "outputSchema": _schema_uri(args.schema_base, "schemas/capability-output.schema.json"),
273
329
  "effect": effect,
274
330
  "idempotency": idempotency,
275
- "dataClasses": ["public"],
331
+ "dataClasses": data_classes,
276
332
  "profile": _profile(args, args.schema_base),
277
333
  }
278
334
  ],
@@ -282,6 +338,11 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
282
338
  presets = _cost_presets(args)
283
339
  if presets:
284
340
  manifest["costPresets"] = presets
341
+ if defaulted:
342
+ manifest["extensions"] = {TEMPLATE_KEY: {
343
+ "defaulted": defaulted,
344
+ "note": "The scaffold chose these values. Confirm or replace each one, remove it from this list, and remove this block when the list is empty; check keeps readyForAdmission false while it remains.",
345
+ }}
285
346
  lock = {
286
347
  "contract": "fabric-agent-contract",
287
348
  "version": CONTRACT_VERSION,
@@ -293,6 +354,7 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
293
354
  "$schema": "https://json-schema.org/draft/2020-12/schema",
294
355
  "$id": _schema_uri(args.schema_base, "schemas/capability-input.schema.json"),
295
356
  "title": "Capability input",
357
+ "$comment": TEMPLATE_MARK + ": a generic placeholder; replace it with the capability's real input schema.",
296
358
  "type": "object",
297
359
  "additionalProperties": False,
298
360
  "properties": {"request": {"type": "string", "minLength": 1}},
@@ -302,6 +364,7 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
302
364
  "$schema": "https://json-schema.org/draft/2020-12/schema",
303
365
  "$id": _schema_uri(args.schema_base, "schemas/capability-output.schema.json"),
304
366
  "title": "Capability output",
367
+ "$comment": TEMPLATE_MARK + ": a generic placeholder; replace it with the capability's real output schema.",
305
368
  "type": "object",
306
369
  "additionalProperties": False,
307
370
  "properties": {
@@ -310,8 +373,8 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
310
373
  },
311
374
  "required": ["summary", "evidence"],
312
375
  }
313
- fixture = {"request": "Return one bounded, non-publishing test result."}
314
- assertions = """# Admission probe assertions
376
+ fixture = {"request": FIXTURE_SENTINEL}
377
+ assertions = "<!-- " + TEMPLATE_MARK + ": generic assertions; replace them with what this capability's probe proves. -->\n" + """# Admission probe assertions
315
378
 
316
379
  - The result validates against `capability-output.schema.json`.
317
380
  - Every claimed fact has a resolvable evidence URI.
@@ -324,10 +387,10 @@ Contract: `fabric-agent-contract` 0.1.0 at `%s`
324
387
 
325
388
  | Gate | Status | Receipt / next action |
326
389
  |---|---|---|
327
- | Declaration shape | NOT VERIFIED | Run `adapt_project.py check . --contract /path/to/fabric-agent-contract`. |
328
- | Protocol revision and connection | NOT VERIFIED | Replace profile placeholders, then negotiate the exact pinned revision. |
329
- | Semantic probes | NOT VERIFIED | Implement and run the bounded fixture and assertions against the provider. |
330
- | Project binding readiness | NOT VERIFIED | Requires an admitted capability and Fabric host runtime. |
390
+ | Declaration shape | NOT_VERIFIED | Run `adapt_project.py check . --contract /path/to/fabric-agent-contract`. |
391
+ | Protocol revision and connection | NOT_VERIFIED | Replace profile placeholders, then negotiate the exact pinned revision. |
392
+ | Semantic probes | NOT_VERIFIED | Implement and run the bounded fixture and assertions against the provider. |
393
+ | Project binding readiness | NOT_VERIFIED | Requires an admitted capability and Fabric host runtime. |
331
394
 
332
395
  The zero content hash, `replace-me` values, and `.invalid` endpoints are deliberate
333
396
  placeholders. Scaffolding is not admission and must not be presented as compatibility.
@@ -345,7 +408,7 @@ placeholders. Scaffolding is not admission and must not be presented as compatib
345
408
 
346
409
  EFFECT_CLASSES = ("none", "draft", "publish", "send", "reply", "charge", "delete", "merge", "deploy", "change-policy")
347
410
  IDEMPOTENCY = ("required", "supported", "none")
348
- _PRESET_ID = re.compile(r"^[a-z][a-z0-9-]{0,39}$")
411
+ _PRESET_ID = re.compile(r"^[a-z][a-z0-9-]{0,39}\Z") # \Z, not `$`: `$` also matches before a trailing newline (R1)
349
412
 
350
413
 
351
414
  def _cost_presets(args: argparse.Namespace) -> List[Dict[str, Any]]:
@@ -393,6 +456,23 @@ def scaffold_project(root: Path, args: argparse.Namespace) -> Dict[str, Any]:
393
456
  raise AdaptationError("project path is not a directory: %s" % root)
394
457
  generated = _generated_files(args)
395
458
  collisions = [rel for rel in generated if (root / rel).exists()]
459
+ if getattr(args, "dry_run", False):
460
+ diffs: Dict[str, str] = {}
461
+ for rel in collisions:
462
+ try:
463
+ old = (root / rel).read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
464
+ except OSError as exc:
465
+ old = ["<unreadable: %s>\n" % exc.__class__.__name__]
466
+ diffs[rel] = "".join(difflib.unified_diff(old, generated[rel].splitlines(keepends=True), fromfile=rel, tofile=rel))
467
+ return {
468
+ "project": str(root.resolve()),
469
+ "profile": args.profile,
470
+ "dryRun": True,
471
+ "wouldCreate": sorted(set(generated) - set(collisions)),
472
+ "wouldReplace": sorted(collisions),
473
+ "needsForce": bool(collisions) and not args.force,
474
+ "diffs": diffs,
475
+ }
396
476
  if collisions and not args.force:
397
477
  raise AdaptationError("refusing to overwrite existing paths: %s" % ", ".join(collisions))
398
478
  for rel, content in generated.items():
@@ -413,8 +493,8 @@ def _load_json(path: Path, errors: List[str]) -> Optional[Any]:
413
493
  return json.loads(path.read_text(encoding="utf-8"))
414
494
  except FileNotFoundError:
415
495
  errors.append("missing:%s" % path.name)
416
- except (OSError, json.JSONDecodeError) as exc:
417
- errors.append("invalid-json:%s:%s" % (path.name, exc))
496
+ except (OSError, ValueError) as exc: # ValueError: JSONDecodeError and UnicodeDecodeError (a non-UTF-8 file)
497
+ errors.append("invalid-json:%s:%s" % (path.name, exc.__class__.__name__ if isinstance(exc, UnicodeError) else exc))
418
498
  return None
419
499
 
420
500
 
@@ -426,7 +506,9 @@ def _walk_keys(value: Any, prefix: str = "") -> Iterable[Tuple[str, Any]]:
426
506
  yield from _walk_keys(item, here)
427
507
  elif isinstance(value, list):
428
508
  for index, item in enumerate(value):
429
- yield from _walk_keys(item, "%s[%d]" % (prefix, index))
509
+ here = "%s[%d]" % (prefix, index)
510
+ yield here, item
511
+ yield from _walk_keys(item, here)
430
512
 
431
513
 
432
514
  def _contract_shape(manifest: Path, contract: Path, selected_commit: str) -> Tuple[str, List[str]]:
@@ -452,8 +534,9 @@ def _contract_shape(manifest: Path, contract: Path, selected_commit: str) -> Tup
452
534
  return "NOT_RUN", ["cannot verify clean contract checkout: %s" % exc]
453
535
  if dirty:
454
536
  return "FAIL", ["contract checkout has modifications; exact selected schema is required"]
455
- if not (contract / "node_modules/.bin/tsx").is_file():
456
- return "NOT_RUN", ["pinned contract dependencies are missing; run pnpm install --frozen-lockfile"]
537
+ tsx = contract / "node_modules/.bin/tsx"
538
+ if not tsx.is_file():
539
+ return "NOT_RUN", ["pinned contract dependencies are missing; run pnpm install --frozen-lockfile in the contract checkout"]
457
540
 
458
541
  code = """import {readFile} from 'node:fs/promises';
459
542
  import {createValidator,validateDocument} from './src/validator.ts';
@@ -465,13 +548,15 @@ import {SCHEMA_PREFIX} from './src/contract.ts';
465
548
  process.exit(result.valid?0:3);
466
549
  })().catch((error) => { console.error(error); process.exit(4); });"""
467
550
  try:
551
+ # The checkout's own tsx, called directly: `pnpm exec` may decide the modules folder is stale and purge and
552
+ # reinstall a checkout that is not this tool's to change (ERR_PNPM_UNSAFE_MODULES_DIR).
468
553
  run = subprocess.run(
469
- ["pnpm", "exec", "tsx", "-e", code, str(manifest.resolve())],
554
+ [str(tsx), "-e", code, str(manifest.resolve())],
470
555
  cwd=str(contract), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
471
556
  timeout=120,
472
557
  )
473
- except FileNotFoundError:
474
- return "NOT_RUN", ["pnpm is unavailable; install the pinned contract dependencies"]
558
+ except OSError as exc:
559
+ return "NOT_RUN", ["the contract checkout's tsx could not start (%s); is node on PATH?" % exc.__class__.__name__]
475
560
  except subprocess.TimeoutExpired:
476
561
  return "NOT_RUN", ["contract shape validation timed out"]
477
562
  if run.returncode != 0:
@@ -534,6 +619,58 @@ def _plugin_problems(root: Path) -> List[str]:
534
619
  return problems
535
620
 
536
621
 
622
+ def _template_block_warnings(manifest: Dict[str, Any], errors: List[str]) -> List[str]:
623
+ """The pointers the scaffold recorded as its own choices (TEMPLATE_KEY); each stays a warning until removed."""
624
+ extensions = manifest.get("extensions")
625
+ block = extensions.get(TEMPLATE_KEY) if isinstance(extensions, dict) else None
626
+ if block is None:
627
+ return []
628
+ pointers = block.get("defaulted") if isinstance(block, dict) else None
629
+ if not isinstance(pointers, list) or not all(isinstance(p, str) for p in pointers):
630
+ errors.append("extensions.%s must be {\"defaulted\": [JSON pointers]}" % TEMPLATE_KEY)
631
+ return []
632
+ if not pointers:
633
+ return ["template-default:extensions.%s is empty; remove the block" % TEMPLATE_KEY]
634
+ return ["template-default:%s (chosen by the scaffold; confirm or replace it, then remove it from extensions.%s)" % (p, TEMPLATE_KEY)
635
+ for p in pointers]
636
+
637
+
638
+ def _template_file_warnings(root: Path) -> List[str]:
639
+ """Sentinels in the generated files other than the manifest: placeholder text, the template mark, the generic
640
+ schemas and fixture (also recognised by shape when the mark was removed but the schema was not replaced)."""
641
+ warnings: List[str] = []
642
+ for rel in GENERATED_PATHS[1:]:
643
+ if rel == "fabric/FABRIC-CONFORMANCE.md":
644
+ continue # the status page names the placeholders to explain them; it is the author's to keep
645
+ path = root / rel
646
+ if not path.is_file():
647
+ continue
648
+ text = _read_text(path)
649
+ if TEMPLATE_MARK in text:
650
+ warnings.append("placeholder:%s (the scaffold's generic template)" % rel)
651
+ elif any(marker in text for marker in PLACEHOLDER_TEXT):
652
+ warnings.append("placeholder:%s" % rel)
653
+ generic_shapes = {
654
+ "fabric/schemas/capability-input.schema.json": {"request"},
655
+ "fabric/schemas/capability-output.schema.json": {"summary", "evidence"},
656
+ }
657
+ for rel, keys in generic_shapes.items():
658
+ try:
659
+ schema = json.loads((root / rel).read_text(encoding="utf-8"))
660
+ except (OSError, ValueError):
661
+ continue
662
+ properties = schema.get("properties") if isinstance(schema, dict) else None
663
+ if isinstance(properties, dict) and set(properties) == keys and TEMPLATE_MARK not in json.dumps(schema):
664
+ warnings.append("placeholder:%s (still the scaffold's generic shape)" % rel)
665
+ try:
666
+ fixture = json.loads((root / "fabric/fixtures/admission-input.json").read_text(encoding="utf-8"))
667
+ except (OSError, ValueError):
668
+ fixture = None
669
+ if isinstance(fixture, dict) and FIXTURE_SENTINEL in fixture.values():
670
+ warnings.append("placeholder:fabric/fixtures/admission-input.json (the scaffold's generic fixture)")
671
+ return warnings
672
+
673
+
537
674
  def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
538
675
  errors: List[str] = []
539
676
  warnings: List[str] = []
@@ -601,12 +738,18 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
601
738
  normalized = key.lower().replace("_", "").replace("-", "")
602
739
  if any(secret in normalized for secret in ("password", "apikey", "accesstoken", "clientsecret", "privatekey")):
603
740
  errors.append("secret-like field is forbidden in manifest: %s" % key)
604
- if isinstance(value, str) and ("replace-me" in value or ".invalid" in value):
741
+ if isinstance(value, str) and any(text in value for text in PLACEHOLDER_TEXT):
605
742
  warnings.append("placeholder:%s" % key)
743
+ if value == AUTHOR_SENTINEL:
744
+ warnings.append("placeholder:%s" % key)
745
+ if value == WEAK_ASSERTION:
746
+ warnings.append("placeholder:%s (a generic probe assertion; state what the probe proves)" % key)
606
747
  errors.extend(_credential_problems(manifest))
607
748
  provider = manifest.get("provider")
608
- if isinstance(provider, dict) and provider.get("contentHash") == "sha256:" + ("0" * 64):
749
+ if isinstance(provider, dict) and provider.get("contentHash") == ZERO_HASH:
609
750
  warnings.append("placeholder:provider.contentHash")
751
+ warnings.extend(_template_block_warnings(manifest, errors))
752
+ warnings.extend(_template_file_warnings(root))
610
753
 
611
754
  shape_status = "NOT_RUN"
612
755
  shape_receipts = ["pass --contract PATH pointing at the pinned checkout"]
@@ -673,7 +816,13 @@ def build_parser() -> argparse.ArgumentParser:
673
816
  help="a fixed cost option a launcher offers (DEC-0037 costPresets); repeat for each")
674
817
  scaffold.add_argument("--default-cost-preset", metavar="ID", help="the preset preselected at launch")
675
818
  scaffold.add_argument("--job", action="store_true", help="the capability's work may outlive one request (fabric-interop/0.1 job)")
676
- scaffold.add_argument("--force", action="store_true")
819
+ scaffold.add_argument("--created-by", metavar="URI", help="the manifest author (provider.createdBy); a sentinel until given")
820
+ scaffold.add_argument("--identity-method", choices=IDENTITY_METHODS, help="provider.identity.method; a remote default of tls is a sentinel until given")
821
+ scaffold.add_argument("--data-class", action="append", choices=DATA_CLASSES, default=[],
822
+ help="a data class the capability handles; repeat. Without it [public] is written as a sentinel")
823
+ scaffold.add_argument("--probe-assertion", action="append", default=[], help="what the admission probe proves; repeat. Without it a generic sentinel is written")
824
+ scaffold.add_argument("--force", action="store_true", help="replace the generated paths that exist; see --dry-run first")
825
+ scaffold.add_argument("--dry-run", action="store_true", help="print the planned writes and a unified diff of each existing path; write nothing")
677
826
  scaffold.add_argument("--json", action="store_true")
678
827
 
679
828
  check = sub.add_parser("check", help="check generated structure and optional pinned schema")
@@ -692,7 +841,13 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
692
841
  result = scaffold_project(args.project, args)
693
842
  else:
694
843
  result = check_project(args.project, args.contract)
695
- _print(result, args.json)
844
+ if result.get("dryRun") and not args.json:
845
+ diffs = result.pop("diffs")
846
+ _print(result, False)
847
+ for rel in sorted(diffs):
848
+ print(diffs[rel], end="" if diffs[rel].endswith("\n") or not diffs[rel] else "\n")
849
+ else:
850
+ _print(result, args.json)
696
851
  if args.command == "check":
697
852
  gates = result["gates"]
698
853
  if gates["localStructure"]["status"] == "FAIL" or gates["declarationShape"]["status"] == "FAIL":
@@ -7,7 +7,7 @@ This is an output correctness gate, not authentication, discovery, or a relay.
7
7
  import json
8
8
  import re
9
9
  import sys
10
- from urllib.parse import parse_qs, urlsplit
10
+ from urllib.parse import parse_qs, parse_qsl, unquote, urlsplit
11
11
 
12
12
 
13
13
  def require(condition, reason):
@@ -15,6 +15,63 @@ def require(condition, reason):
15
15
  raise ValueError(reason)
16
16
 
17
17
 
18
+ # Credential names and values: the same rules as fabric-button.js (credentialName, secretValue).
19
+ CREDENTIAL_WORDS = {
20
+ 'token', 'secret', 'password', 'passwd', 'pass', 'passphrase', 'credential', 'credentials', 'cookie',
21
+ 'authorization', 'bearer', 'jwt', 'otp', 'apikey', 'signature', 'sig', 'accesstoken', 'idtoken', 'authtoken',
22
+ 'refreshtoken', 'sessionid', 'sessiontoken', 'logincode', 'authcode', 'privatekey', 'clientsecret', 'secretkey',
23
+ 'accesskey'}
24
+ CREDENTIAL_PAIRS = {
25
+ 'key': {'private', 'api', 'access', 'secret', 'session', 'auth', 'signing', 'encryption', 'master', 'client', 'login',
26
+ 'refresh'},
27
+ 'auth': {'token', 'code', 'header'},
28
+ 'session': {'token', 'id'},
29
+ 'code': {'login', 'auth', 'access', 'session'}}
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)
33
+ LONG_RUN = re.compile(r'[A-Za-z0-9+/=_-]{40,}')
34
+
35
+
36
+ def name_words(name):
37
+ name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', name)
38
+ name = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1 \2', name)
39
+ return [w.lower() for w in re.split(r'[\s._-]+', name) if w]
40
+
41
+
42
+ def credential_name(name):
43
+ split = name_words(name)
44
+ words = set(split) | {w[:-1] for w in split if len(w) > 2 and w.endswith('s')}
45
+ if words & CREDENTIAL_WORDS:
46
+ return True
47
+ if len(split) == 1 and words & set(CREDENTIAL_PAIRS):
48
+ return True
49
+ return any(w in words and words & pairs for w, pairs in CREDENTIAL_PAIRS.items())
50
+
51
+
52
+ def secret_value(value):
53
+ long_secret = any(re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) and re.search(r'[0-9]', run)
54
+ for run in LONG_RUN.findall(value))
55
+ return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or long_secret)
56
+
57
+
58
+ def carries_credential(text):
59
+ # A query or fragment whose names or values could be a credential: refused even with an empty value.
60
+ return (secret_value(text) or secret_value(unquote(text))
61
+ or any(credential_name(name) or secret_value(name) or secret_value(value)
62
+ for name, value in parse_qsl(text, keep_blank_values=True)))
63
+
64
+
65
+ def login_path(path):
66
+ # The path a server routes: refuse what normalises differently in different servers, then compare.
67
+ decoded = unquote(path)
68
+ require(not re.search(r'%2f|%5c', path, re.I) and '\\' not in decoded, 'encoded_separator')
69
+ segments = decoded.split('/')[1:]
70
+ require(not any(seg in ('.', '..') for seg in segments), 'dot_segment')
71
+ require(all(seg for seg in segments[:-1]), 'empty_segment')
72
+ return '/'.join([''] + [seg for seg in segments if seg]).lower() == '/fabric/v1/login'
73
+
74
+
18
75
  def safe_path(path):
19
76
  return (isinstance(path, str) and path.startswith('/') and not path.startswith('//')
20
77
  and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path))
@@ -42,10 +99,14 @@ def check(data):
42
99
  and not plain.username and not plain.password, 'invalid_http_fallback')
43
100
  http_page = plain.path + ('?' + plain.query if plain.query else '') + ('#' + plain.fragment if plain.fragment else '')
44
101
  require(safe_path(http_page) and http_page == (page or '/'), 'mismatched_page')
45
- sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code'}
46
- page_query = parse_qs(urlsplit(http_page).query)
47
- require(not sensitive.intersection(k.lower() for k in page_query), 'credential_query')
48
- require(urlsplit(http_page).path != '/fabric/v1/login', 'login_link_is_not_dashboard')
102
+ sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code', 'code', 'api_key',
103
+ 'apikey', 'key', 'auth'}
104
+ parts = urlsplit(http_page)
105
+ places = [parts.query, parts.fragment] + parts.fragment.split('?', 1)[1:]
106
+ require(not any(sensitive.intersection(k.lower() for k in parse_qs(p, keep_blank_values=True)) for p in places),
107
+ 'credential_query')
108
+ require(not any(carries_credential(p) for p in places), 'credential_query')
109
+ require(not login_path(parts.path), 'login_link_is_not_dashboard')
49
110
  state = host.get('state')
50
111
  require(state in ('available', 'not_installed', 'unknown', 'incompatible', 'handler_mismatch', 'unsupported'), 'unknown_host_state')
51
112
  target, viewer = data.get('target_device'), data.get('viewer_device')