@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.
- package/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- 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
|
|
87
|
-
|
|
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.
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md
CHANGED
|
@@ -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.
|
|
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
|
|
50
|
-
absolute or `~/` path to `fabric-agent.json
|
|
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)`,
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md
CHANGED
|
@@ -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:
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -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
|
-
|
|
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":
|
|
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":
|
|
315
|
+
"createdBy": created_by,
|
|
260
316
|
"name": args.provider_name,
|
|
261
317
|
"identity": {
|
|
262
318
|
"subject": provider_id,
|
|
263
|
-
"method":
|
|
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":
|
|
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":
|
|
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 |
|
|
328
|
-
| Protocol revision and connection |
|
|
329
|
-
| Semantic probes |
|
|
330
|
-
| Project binding readiness |
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
[
|
|
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
|
|
474
|
-
return "NOT_RUN", ["
|
|
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 (
|
|
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") ==
|
|
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("--
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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')
|