@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/README.md +5 -3
- package/SECURITY.md +4 -2
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +9 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +239 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
- 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/references/board.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +22 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +87 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +29 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -138
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +239 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +117 -19
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.82.0 (2026-11-12)
|
|
4
|
+
|
|
5
|
+
Wave 4 of the backlog sweep of 2026-11-12. The contract pin stays at `93e7db16` (`v0.1.0+2026-11-08.1`). Kit modules
|
|
6
|
+
change: `fabric_secrets`, `fabric_connections`, `fabric_peer_owner` (`1.2.0+kit.6`), `fabric_descriptor`,
|
|
7
|
+
`fabric_holds`, `fabric_hub`, `fabric_lock`, the facade `fabric_service`, `fabric-delegation.mjs`, and their Node twins;
|
|
8
|
+
dashboard kit 1.1.7.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **working-with-fabric answers a board request as contract DEC-0152 SA-6 says** (FAA-311): a session reads the
|
|
13
|
+
capability's slot in `com.status` first; at an `active` slot, or an enroll refused `fenced`, it leaves the request to
|
|
14
|
+
that holder, sends nothing and asks no one; it declines only at a slot `absent` at generation 0, and asks the operator
|
|
15
|
+
with `fabric_question_ask` after any refusal that is not retryable. A session without the slot does no work on a
|
|
16
|
+
request and gives it no state. The exit contract, the preamble, `SKILL.md` and the references say so, the exit
|
|
17
|
+
contract within Hermes' 4000 characters (3996).
|
|
18
|
+
- **The peer owner reads DEC-0155's restricted table** (FAA-312): a `netstat` that fails or exits non-zero, a macOS
|
|
19
|
+
table with no row of another uid (sysctl) or process (netstat), and one without the reader's own connection read
|
|
20
|
+
`table-restricted` (RT-1); Linux and Windows keep `no-row`. Under a restricted table a listener is this account's only
|
|
21
|
+
on exactly `127.0.0.1` (RT-4). The launchd vouch takes only a listen on exactly `127.0.0.1` (RT-3), never the IPv4
|
|
22
|
+
wildcard or `::1`, and nothing from a set-uid reader: the kit's wildcard departure ends. `PEER_OWNER_VERSION` is
|
|
23
|
+
`1.2.0+kit.6`.
|
|
24
|
+
- **The kit's hub locator answers a pid another OS account holds as the session hooks do** (FAA-304, after FAA-254):
|
|
25
|
+
`pid_alive` / `pidAlive` give None / null and `locate_hub` / `locateHub` refuse with `not-a-hub`, retryable, "not
|
|
26
|
+
verified", never `not-running`.
|
|
27
|
+
- **`fabric_secrets.secret_url` refuses a URL whose host or port `new URL` refuses** (FAA-309), as `secretUrl` does:
|
|
28
|
+
the WHATWG host reading and the IDNA tables of FAA-98 move into `fabric_secrets` (`fabric_connections` imports them;
|
|
29
|
+
new `url_refused`). `repository_credential` still reads a repository's parameters whatever its host.
|
|
30
|
+
- **One-pass JWT reading** (FAA-313): `has_jwt` / `jwt_spans` (Node `hasJwt` / `jwtSpans`), the same answers in linear
|
|
31
|
+
time; `secret_value`, `secret_argument`, the channel-agent check and `fabric_source_tree` use it.
|
|
32
|
+
- **The holds twins agree on whitespace** (FAA-316): both squash a `statusMessage` with one ECMAScript class, so
|
|
33
|
+
U+001C, U+0085 and U+FEFF read the same; four vectors in `test/fixtures/holds/waiting-vectors.json`.
|
|
34
|
+
- **The probe's `lifecycle.one-copy` finds a LaunchDaemon's leftover LaunchAgent** (FAA-317) in its `UserName`'s
|
|
35
|
+
`gui/<uid>` first, then in the probe's own; `in_launch_daemons` is `fabric_supervisors._in_daemons`, imported.
|
|
36
|
+
- **Dashboard kit 1.1.7** (FAA-314): `fabric-button.js` reads credentials with DEC-0151's `SECRET_VALUE`, held to the
|
|
37
|
+
contract's `secretValue` and `credentialName` vectors.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **A copy holding the instance lock stops when its lock file is replaced** (FAA-303, the 0.79.0 seam reading). On
|
|
42
|
+
macOS `flock` and `O_EXLOCK` lock an inode, so a data folder swapped under a running copy let a second copy lock the
|
|
43
|
+
new file and start. `hold_single_instance` / `holdSingleInstance` now start `InstanceLock.watch` /
|
|
44
|
+
`InstanceLock#watch` there: every `LOCK_WATCH_SECONDS` (2 s) it compares the locked inode with the file at the path,
|
|
45
|
+
and once the path names another file the copy exits 75 with one sentence naming the path (`lost_lock_message` /
|
|
46
|
+
`lostLockMessage`; `on_lost` / `onLost` replaces that reaction). The two copies overlap for at most one interval.
|
|
47
|
+
Linux and Windows keep their path-keyed name and are unchanged.
|
|
48
|
+
- **Tests that failed under load** (FAA-308): the linear-time tests for `raw_values` and the minified JSON line read the
|
|
49
|
+
thread's CPU time, not the wall clock (kept on Windows), so a load near 110 no longer fails them, while a quadratic
|
|
50
|
+
implementation still does; the board sample's probe test lets `well-known.fast`, which times the machine, go either
|
|
51
|
+
way, as `test_probe_interop` does.
|
|
52
|
+
- A flaky test drew a SAS signature without a digit about once in 1500 runs, a shape the long-run rule excludes by its
|
|
53
|
+
definition; it now draws one in the rule's shape.
|
|
54
|
+
|
|
55
|
+
- **What the 0.82.0 seam reading found, fixed before the release**: Python's credential readers
|
|
56
|
+
(`fabric_secrets.url_refused` and its copy, `fabric_connections._whatwg_parts`, `check_service_channel_agent.url_parts`)
|
|
57
|
+
refuse a URL whose empty host is followed by `:`, with or without a port, as `new URL` does (`foo://:/?token=...`;
|
|
58
|
+
four shared vectors in both suites). `adapt_project`'s LC-21 gates read a stored URL's credential without the host
|
|
59
|
+
check, so a password in a manifest or `plugin.json` URL is found whatever its host. Every other reader of the JWT rule
|
|
60
|
+
reads it in one pass too (`fabric_source_check`, `fabric_connections` and its Node twin, the three
|
|
61
|
+
`check_dashboard_link.py` copies, `fabric-button.js`). Python `pid_alive` on Windows (`fabric_hub`,
|
|
62
|
+
`fabric_session_env`) answers None when OpenProcess is refused with access denied, as Node's EPERM does. The README
|
|
63
|
+
states the preamble's request rule; the exit contract's closing checkpoint names its `status` again (done if
|
|
64
|
+
completed, blocked if failed_known); the hub and HTTP docstrings say a missing row on macOS is `table-restricted`.
|
|
65
|
+
New rows FAA-320 to FAA-323.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **`fabric_peer_owner.py listener <port> [--json]`** and **`fabric-peer-owner.mjs listener <port>`** print the
|
|
70
|
+
listener's kind (FAA-315): the route for the dashboard link gate's `listener_owner`, named in the three
|
|
71
|
+
`dashboard-links.md`.
|
|
72
|
+
|
|
73
|
+
### Documented
|
|
74
|
+
|
|
75
|
+
- On a host with a restricted socket table the dashboard layout probe measures nothing: every rule `NOT_RUN`, exit 3
|
|
76
|
+
(FAA-318). It does not vouch by the service's pid, which DEC-0155 RT-3 reserves for the caller that started it.
|
|
77
|
+
- FAA-292's decision: RT-2 through libproc waits for an L0 module of its own, as `fabric_peer_owner.py` is at the
|
|
78
|
+
1000-line cap (`docs/evidence/specs/2026-11-12-faa-292-libproc-decision.md`); the row stays open.
|
|
79
|
+
- `fabric-delegation.mjs` carries the `<https>` legend; `HTTPS_LEGEND_PENDING` in `test/validate.py` is empty (FAA-306).
|
|
80
|
+
|
|
3
81
|
## 0.81.0 (2026-11-12)
|
|
4
82
|
|
|
5
83
|
Wave 3 of the backlog sweep of 2026-11-12. **The contract pin moves to `93e7db16` (`v0.1.0+2026-11-08.1`)**: DEC-0149
|
package/README.md
CHANGED
|
@@ -23,10 +23,12 @@ The skills are `adapting-projects-to-fabric`, `creating-fabric-agents`,
|
|
|
23
23
|
`working-with-fabric` is for the other side: a coding agent (Claude Code, Codex, Kimi Code, Kilo, Hermes) opened in
|
|
24
24
|
a terminal that works in a project through Fabric. It finds Fabric (`hub.json`, its `pid`), has the machine's
|
|
25
25
|
lifecycle broker start it when it is down, registers `fabric-mcp` once with no secret, calls `fabric_whoami`
|
|
26
|
-
first, reads the project board (`com.status`, `com.list`, `com.read_ack` only what was read)
|
|
27
|
-
Project in the same turn
|
|
26
|
+
first, reads the project board (`com.status`, `com.list`, `com.read_ack` only what was read): an announcement or
|
|
27
|
+
question waiting on its Project gets its state in the same turn (the result later), and a request to its Project is
|
|
28
|
+
read through its capability's slot in `com.status` (`active` is left to its holder and nobody is asked; otherwise
|
|
29
|
+
`com.enroll`, and only the slot's holder gives the request its state; before Fabric 0.3.17 a run Fabric starts
|
|
28
30
|
declines a request no session can take and the asker resends, and from 0.3.17 the session's own Project is granted
|
|
29
|
-
`board.respond` at `com.enroll`, CO-506)
|
|
31
|
+
`board.respond` at `com.enroll`, CO-506). It asks other
|
|
30
32
|
Projects only through `com.submit`, asks who is working on what and claims its task before it starts
|
|
31
33
|
(`fabric_work_list`, `fabric_work_claim`, Fabric v0.3.5 and later), links tasks to backlog rows instead of copying them, and
|
|
32
34
|
releases at the end (`fabric_work_release`). `scripts/fabric_session.py status` prints where Fabric stands and one
|
package/SECURITY.md
CHANGED
|
@@ -57,7 +57,8 @@ bounded and non-publishing until explicit scoped grants exist in a project bindi
|
|
|
57
57
|
short-lived child of its own interpreter listening on a random `127.0.0.1` port; when the
|
|
58
58
|
table is restricted (macOS 27.2, FAA-267) the launchd installers also run `/usr/sbin/lsof
|
|
59
59
|
-nP -a -p <pid> -i4TCP -sTCP:LISTEN -Fn` on their own job's pid. That vouch shows the job listens on the port, not that it accepted
|
|
60
|
-
the connection
|
|
60
|
+
the connection; it counts only a listen on exactly `127.0.0.1`, as the pin's DEC-0155 RT-3 asks (since FAA-312; a wildcard
|
|
61
|
+
or `::1` listener is never vouched for, and a set-uid reader vouches for nothing). A LaunchDaemon's root
|
|
61
62
|
installer runs that `lsof` and `ps -o uid=` on its job's pid before it accepts an answer, and reads, never changes, every other
|
|
62
63
|
plist in `/Library/LaunchDaemons` (to find another holder of its label, `plutil -convert xml1` for a binary one in
|
|
63
64
|
Node), `launchctl print system/<label>` and the `UserName`'s uid (`pwd`, or `id -u` in Node). The kit's loopback server reads the table once per accepted
|
|
@@ -80,7 +81,8 @@ bounded and non-publishing until explicit scoped grants exist in a project bindi
|
|
|
80
81
|
connected peer's address, so the Authorization header never reaches another account's proxy (FAA-260 items 1, 8).
|
|
81
82
|
- The layout probe (`check_dashboard_layout.mjs`) reads a raw loopback page's listener from the socket table just
|
|
82
83
|
before its headless browser opens it, the one-time login URL included, and opens nothing on another account's,
|
|
83
|
-
root's or an untellable listener (FAA-260 items 2, 14)
|
|
84
|
+
root's or an untellable listener (FAA-260 items 2, 14); under a restricted table (DEC-0155) that is every listener,
|
|
85
|
+
so it measures nothing there and never vouches by a pid it did not start (FAA-318). The browser then connects on its own: a listener swapped
|
|
84
86
|
between the read and the browser's connect is a residue, bounded by the login code's 120 s and single use.
|
|
85
87
|
- The agents database (FAA-275): creating-fabric-agents, adapting-projects-to-fabric, building-fabric-services and
|
|
86
88
|
working-with-fabric may send board requests to whichever Project the board lists under an `agents-db.*` capability
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.82.0",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "fabric-agent-adapter",
|
|
4
4
|
"displayName": "Fabric Agent Adapter",
|
|
5
5
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks; and put an agent on a standing watch over a product (observer mode).",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.82.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -9,7 +9,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
9
9
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
10
10
|
metadata:
|
|
11
11
|
author: PassionCode.ai
|
|
12
|
-
version: "0.
|
|
12
|
+
version: "0.82.0"
|
|
13
13
|
contract-version: "0.1.0"
|
|
14
14
|
contract-commit: "93e7db164c7829e2783b4b7705c4113bb5201131"
|
|
15
15
|
---
|
|
@@ -123,7 +123,15 @@ if_absent policy, the same device and `"listener_owner": "this-account"`: the ki
|
|
|
123
123
|
the host read for the listener on http_url's port (`fabric_peer_owner`'s
|
|
124
124
|
`this-account`, `another-account`, `system`, `unknown`, `none`, or
|
|
125
125
|
`check_service.py`'s `foreign-user`); any other, or none given, refuses the
|
|
126
|
-
browser (DEC-0154 PL-3, FAA-251). The
|
|
126
|
+
browser (DEC-0154 PL-3, FAA-251). The host reads that kind right before it opens
|
|
127
|
+
the page with the adapter's
|
|
128
|
+
`python3 building-fabric-services/scripts/fabric_peer_owner.py listener <port>`
|
|
129
|
+
(or `node building-fabric-services/scripts/fabric-peer-owner.mjs listener <port>`),
|
|
130
|
+
which prints it on one line (`--json` prints the whole owner) and sends nothing
|
|
131
|
+
to the port (FAA-315). Where the socket table is restricted (macOS 27.2 for an
|
|
132
|
+
ad-hoc-signed Python or Node, DEC-0155 RT-4) only this account's listener on
|
|
133
|
+
exactly `127.0.0.1` reads `this-account`; a wildcard or `::1` bind reads
|
|
134
|
+
`unknown` (`table-restricted`), and the browser is refused. The native link does not take it: Fabric
|
|
127
135
|
Dashboards checks its own listener. Contract DEC-0123 PL-3 also allows a browser
|
|
128
136
|
on `unsupported` (a system the host does not ship for); the gate treats
|
|
129
137
|
`unsupported` as no fallback, by the operator's rule that a browser opens only on a
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -659,6 +659,13 @@ import {SCHEMA_PREFIX} from './src/contract.ts';
|
|
|
659
659
|
return "PASS", ["manifest validated by selected contract schema at %s" % selected_commit]
|
|
660
660
|
|
|
661
661
|
|
|
662
|
+
def _stored_url_credential(url: str) -> bool:
|
|
663
|
+
"""A credential in a URL a manifest or plugin.json STORES (LC-21): secret_url's reading with the host not checked,
|
|
664
|
+
as repository_credential reads a repository. secret_url reads a URL whose host `new URL` refuses as an argument only
|
|
665
|
+
(FAA-309), right for a reader, but storage is the closed side: a password stays a password whatever its host."""
|
|
666
|
+
return _secrets._url_credential(url, hosts=False)
|
|
667
|
+
|
|
668
|
+
|
|
662
669
|
def _credential_problems(manifest: Dict[str, Any]) -> List[str]:
|
|
663
670
|
"""LC-21 (FAA-23): no credential in a launch argument or in a URL the manifest names."""
|
|
664
671
|
problems: List[str] = []
|
|
@@ -670,7 +677,7 @@ def _credential_problems(manifest: Dict[str, Any]) -> List[str]:
|
|
|
670
677
|
if isinstance(arg, str) and _secrets.secret_argument(arg):
|
|
671
678
|
problems.append("LC-21: capability[%d] passes a credential in a launch argument; argv is visible to every process" % index)
|
|
672
679
|
for field in ("url", "agentCardUrl"):
|
|
673
|
-
if isinstance(connection.get(field), str) and
|
|
680
|
+
if isinstance(connection.get(field), str) and _stored_url_credential(connection[field]):
|
|
674
681
|
problems.append("LC-21: capability[%d].%s carries a credential in the URL; a query lands in every log" % (index, field))
|
|
675
682
|
return problems
|
|
676
683
|
|
|
@@ -712,7 +719,7 @@ def _plugin_problems(root: Path) -> List[str]:
|
|
|
712
719
|
if "command" in entry:
|
|
713
720
|
problems.append("LC-19: plugin %s declares the stdio MCP server %s, which starts in every session that enables the plugin; serve it over HTTP from one resident process or register it at project scope" % (name, server))
|
|
714
721
|
argv = [entry.get("command")] + list(entry.get("args") or [])
|
|
715
|
-
if any(isinstance(a, str) and _secrets.secret_argument(a) for a in argv) or (isinstance(entry.get("url"), str) and
|
|
722
|
+
if any(isinstance(a, str) and _secrets.secret_argument(a) for a in argv) or (isinstance(entry.get("url"), str) and _stored_url_credential(entry["url"])):
|
|
716
723
|
problems.append("LC-21: plugin %s's MCP server %s carries a credential in its argv or URL" % (name, server))
|
|
717
724
|
env = entry.get("env") if isinstance(entry.get("env"), dict) else {}
|
|
718
725
|
if any(isinstance(v, str) and _secret_env_value(v) for v in env.values()): # path-aware for a path (FAA-266)
|
|
@@ -28,6 +28,24 @@ CREDENTIAL_PAIRS = {
|
|
|
28
28
|
'session': {'token', 'id'},
|
|
29
29
|
'code': {'login', 'auth', 'access', 'session'}}
|
|
30
30
|
JWT_SHAPE = re.compile(r'eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*')
|
|
31
|
+
URL_BASE64_RUN = re.compile(r'[A-Za-z0-9_-]*')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def has_jwt(text):
|
|
35
|
+
# Whether JWT_SHAPE matches anywhere, read in one pass (the contract's hasJwt, FAA-313): the expression backtracks
|
|
36
|
+
# from every `eyJ` of a long run, quadratic in it, while every `eyJ` of one run shares that run's end, so only its
|
|
37
|
+
# first is tried. The kit's fabric_secrets.has_jwt reads the same way; this file stands alone, so it has its own.
|
|
38
|
+
at = text.find('eyJ')
|
|
39
|
+
while at >= 0:
|
|
40
|
+
one = URL_BASE64_RUN.match(text, at + 3).end()
|
|
41
|
+
if one - at - 3 >= 2 and text[one:one + 1] == '.':
|
|
42
|
+
two = URL_BASE64_RUN.match(text, one + 1).end()
|
|
43
|
+
if two - one - 1 >= 2 and text[two:two + 1] == '.':
|
|
44
|
+
return True
|
|
45
|
+
at = text.find('eyJ', one)
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
31
49
|
# The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key with its
|
|
32
50
|
# prefix in its canonical case after a boundary (the start, a character that is no ASCII letter or digit, or an escape);
|
|
33
51
|
# an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more.
|
|
@@ -58,7 +76,7 @@ def credential_name(name):
|
|
|
58
76
|
def secret_value(value):
|
|
59
77
|
long_secret = any(re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) and re.search(r'[0-9]', run)
|
|
60
78
|
for run in LONG_RUN.findall(value))
|
|
61
|
-
return bool(
|
|
79
|
+
return bool(has_jwt(value) or SECRET_VALUE.search(value) or long_secret)
|
|
62
80
|
|
|
63
81
|
|
|
64
82
|
def carries_credential(text):
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py
CHANGED
|
@@ -10,8 +10,11 @@ U+001C to U+001F, and not U+FEFF), `.` stopping at every line terminator, ASCII
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import bisect
|
|
14
|
+
import ipaddress
|
|
13
15
|
import re
|
|
14
|
-
from typing import Dict, List, Tuple
|
|
16
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
17
|
+
import unicodedata
|
|
15
18
|
import urllib.parse
|
|
16
19
|
|
|
17
20
|
CREDENTIAL_WORDS = (
|
|
@@ -30,6 +33,34 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
|
|
|
30
33
|
JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
31
34
|
_NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
|
|
32
35
|
JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
|
|
36
|
+
_URL_BASE64_RUN = re.compile(r"[A-Za-z0-9_-]*")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def jwt_spans(text: str, first: bool = False) -> List[Tuple[int, int]]:
|
|
40
|
+
"""The spans JWT_SHAPE matches in a text, leftmost and not overlapping, read in one pass (the contract's `jwtSpans`,
|
|
41
|
+
FAA-313): the regular expression backtracks from every `eyJ` of a long run, quadratic in it, while every `eyJ` of
|
|
42
|
+
one run shares that run's end, so only its first is tried, and a match ends where its third segment's run ends.
|
|
43
|
+
`first` stops at the first match."""
|
|
44
|
+
spans: List[Tuple[int, int]] = []
|
|
45
|
+
at = text.find("eyJ")
|
|
46
|
+
while at >= 0:
|
|
47
|
+
one = _URL_BASE64_RUN.match(text, at + 3).end()
|
|
48
|
+
resume = one
|
|
49
|
+
if one - at - 3 >= 2 and text[one:one + 1] == ".":
|
|
50
|
+
two = _URL_BASE64_RUN.match(text, one + 1).end()
|
|
51
|
+
if two - one - 1 >= 2 and text[two:two + 1] == ".":
|
|
52
|
+
three = _URL_BASE64_RUN.match(text, two + 1).end()
|
|
53
|
+
spans.append((at, three))
|
|
54
|
+
if first:
|
|
55
|
+
break
|
|
56
|
+
resume = three
|
|
57
|
+
at = text.find("eyJ", resume)
|
|
58
|
+
return spans
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_jwt(text: str) -> bool:
|
|
62
|
+
"""Whether JWT_SHAPE matches anywhere in a text, read in one pass (the contract's `hasJwt`, FAA-313)."""
|
|
63
|
+
return bool(jwt_spans(text, True))
|
|
33
64
|
#: Where a provider key's prefix may start (DEC-0151): the start of a value, a character that is no ASCII letter or
|
|
34
65
|
#: digit, or an escape (a percent-escape, single or double, a backslash escape, an octal, `\\x` with two hex digits,
|
|
35
66
|
#: `\\u` with four, an ESC colour sequence, raw or escaped). A consuming group, as the contract writes it.
|
|
@@ -83,7 +114,7 @@ def _long_secret(value: str) -> bool:
|
|
|
83
114
|
|
|
84
115
|
def secret_value(value: str) -> bool:
|
|
85
116
|
"""A JWT anywhere, a provider key or `Bearer …`, or a long base64 run mixing upper case, lower case and digits."""
|
|
86
|
-
return bool(
|
|
117
|
+
return bool(has_jwt(value) or SECRET_VALUE.search(value) or _long_secret(value))
|
|
87
118
|
|
|
88
119
|
|
|
89
120
|
def _path_like(piece: str) -> bool:
|
|
@@ -95,7 +126,7 @@ def _path_like(piece: str) -> bool:
|
|
|
95
126
|
def secret_argument(value: str) -> bool:
|
|
96
127
|
"""The same for a launch argument or a server's command and URL: a long run is looked for between path separators,
|
|
97
128
|
so a folder path holding a session id is not taken for a key."""
|
|
98
|
-
if
|
|
129
|
+
if has_jwt(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
|
|
99
130
|
return True
|
|
100
131
|
for piece in _ARGUMENT_SPLIT.split(value):
|
|
101
132
|
if _path_like(piece):
|
|
@@ -141,6 +172,155 @@ _C0_OR_SPACE = "".join(map(chr, range(0x21)))
|
|
|
141
172
|
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
142
173
|
|
|
143
174
|
|
|
175
|
+
# The WHATWG host parser, enough to know when `new URL` refuses a host (FAA-98, moved here from fabric_connections for
|
|
176
|
+
# secret_url in FAA-309, so the module still ships alone in adapting-projects-to-fabric).
|
|
177
|
+
_FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
|
|
178
|
+
_FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# FAA-98: UTS46 narrowed, not ported. A non-ASCII host is refused only where every Node from 20 to 26 refuses it, so
|
|
182
|
+
# the doubt stays on the closed side (its URL's credentials are read). The two tables are measured by
|
|
183
|
+
# test/fixtures/idna/make_idna_tables.mjs (idna-tables.json, held equal by test_the_idna_tables_are_the_measured_ones):
|
|
184
|
+
# code points refused in every context, and code points removed between letters. The contextual rules read Unicode 3.2
|
|
185
|
+
# classes (`unicodedata.ucd_3_2_0`) and skip what that Unicode does not know, since Node's own UTS46 data lags its
|
|
186
|
+
# Unicode: an Arabic letter of Unicode 14 is no right-to-left letter to it.
|
|
187
|
+
_IDNA_DISALLOWED = (
|
|
188
|
+
(0x80, 0xa0), (0xa8, 0xa8), (0xaf, 0xaf), (0xb4, 0xb4), (0xb8, 0xb8), (0x2d8, 0x2dd), (0x37a, 0x37a),
|
|
189
|
+
(0x384, 0x385), (0x600, 0x605), (0x61c, 0x61c), (0x6dd, 0x6dd), (0x70f, 0x70f), (0x890, 0x891), (0x8e2, 0x8e2),
|
|
190
|
+
(0x1680, 0x1680), (0x1fbd, 0x1fbd), (0x1fbf, 0x1fc1), (0x1fcd, 0x1fcf), (0x1fdd, 0x1fdf), (0x1fed, 0x1fee),
|
|
191
|
+
(0x1ffd, 0x1ffe), (0x2000, 0x200a), (0x200e, 0x200f), (0x2017, 0x2017), (0x2024, 0x2026), (0x2028, 0x202f),
|
|
192
|
+
(0x203e, 0x203e), (0x2047, 0x2049), (0x205f, 0x205f), (0x2066, 0x2069), (0x2100, 0x2101), (0x2105, 0x2106),
|
|
193
|
+
(0x2488, 0x249b), (0x2a74, 0x2a74), (0x2ff0, 0x3000), (0x309b, 0x309c), (0x31ef, 0x31ef), (0x33c2, 0x33c2),
|
|
194
|
+
(0x33c7, 0x33c7), (0x33d8, 0x33d8), (0xe000, 0xf8ff), (0xfc5e, 0xfc63), (0xfdfa, 0xfdfb), (0xfe12, 0xfe13),
|
|
195
|
+
(0xfe16, 0xfe16), (0xfe19, 0xfe19), (0xfe30, 0xfe30), (0xfe47, 0xfe4c), (0xfe52, 0xfe52), (0xfe55, 0xfe56),
|
|
196
|
+
(0xfe5f, 0xfe5f), (0xfe64, 0xfe65), (0xfe68, 0xfe68), (0xfe6a, 0xfe6b), (0xfe70, 0xfe70), (0xfe72, 0xfe72),
|
|
197
|
+
(0xfe74, 0xfe74), (0xfe76, 0xfe76), (0xfe78, 0xfe78), (0xfe7a, 0xfe7a), (0xfe7c, 0xfe7c), (0xfe7e, 0xfe7e),
|
|
198
|
+
(0xff03, 0xff03), (0xff05, 0xff05), (0xff0f, 0xff0f), (0xff1a, 0xff1a), (0xff1c, 0xff1c), (0xff1e, 0xff20),
|
|
199
|
+
(0xff3b, 0xff3e), (0xff5c, 0xff5c), (0xffe3, 0xffe3), (0xfff9, 0xfffd), (0x110bd, 0x110bd), (0x110cd, 0x110cd),
|
|
200
|
+
(0x13430, 0x1343f), (0x1f100, 0x1f100), (0xe0001, 0xe0001), (0xe0020, 0xe007f), (0xf0000, 0xffffd),
|
|
201
|
+
(0x100000, 0x10fffd),
|
|
202
|
+
)
|
|
203
|
+
_IDNA_IGNORED = (
|
|
204
|
+
(0xad, 0xad), (0x34f, 0x34f), (0x180b, 0x180d), (0x180f, 0x180f), (0x200b, 0x200b), (0x2060, 0x2060),
|
|
205
|
+
(0x2064, 0x2064), (0xfe00, 0xfe0f), (0xfeff, 0xfeff), (0x1bca0, 0x1bca3), (0xe0100, 0xe01ef),
|
|
206
|
+
)
|
|
207
|
+
_IDNA_DOTS = re.compile("[.\u3002\uff0e\uff61]")
|
|
208
|
+
_U32 = unicodedata.ucd_3_2_0
|
|
209
|
+
_RTL_OK = {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}
|
|
210
|
+
_JOINERS = "\u200c\u200d"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _in_ranges(ranges: Sequence[tuple], cp: int) -> bool:
|
|
214
|
+
i = bisect.bisect_right(ranges, (cp, 0x10ffff)) - 1
|
|
215
|
+
return i >= 0 and ranges[i][0] <= cp <= ranges[i][1]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _known(c: str) -> bool:
|
|
219
|
+
return _U32.category(c) != "Cn" and unicodedata.category(c) != "Cn"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _joiner_refused(label: str, i: int) -> bool:
|
|
223
|
+
"""CheckJoiners as Node 20 to 26 all read it: they find a joining context, or a virama, anywhere in the label
|
|
224
|
+
(`\u0628a\u200c\u0627` and `\u0628\u200c\u0627\u200d` parse), so only a joiner with nothing but ASCII beside it
|
|
225
|
+
in its label has none for certain."""
|
|
226
|
+
return all(c.isascii() for j, c in enumerate(label) if j != i)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _bidi_refused(label: str) -> bool:
|
|
230
|
+
"""RFC 5893 as Node 20 to 26 all read it: only a label holding R, AL or AN is judged, and its first code point is
|
|
231
|
+
not (Node 26 alone refuses a label opening with a digit or a hyphen)."""
|
|
232
|
+
if not label or not all(_known(c) for c in label) or any(c in _JOINERS for c in label):
|
|
233
|
+
return False # Node 20 to 26 pass a label holding a joiner over (`\u05d0a\u094d\u200d` parses)
|
|
234
|
+
k = [_U32.bidirectional(c) for c in label]
|
|
235
|
+
if not any(x in ("R", "AL", "AN") for x in k):
|
|
236
|
+
return False
|
|
237
|
+
body = list(k)
|
|
238
|
+
while body and body[-1] == "NSM":
|
|
239
|
+
body.pop()
|
|
240
|
+
if not body or body[-1] not in ("R", "AL", "EN", "AN"):
|
|
241
|
+
return True
|
|
242
|
+
return k[0] in ("R", "AL") and (any(x not in _RTL_OK for x in k) or ("EN" in k and "AN" in k))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _idna_host(host: str) -> Optional[str]:
|
|
246
|
+
"""None where WHATWG's domain-to-ASCII refuses a non-ASCII host for certain; else the host as UTS46 maps it, read
|
|
247
|
+
as NFKC with case folding (exact enough for the ASCII rules that follow, which read digits and dots)."""
|
|
248
|
+
if any(_in_ranges(_IDNA_DISALLOWED, ord(c)) for c in host):
|
|
249
|
+
return None
|
|
250
|
+
kept = "".join(c for c in host if not _in_ranges(_IDNA_IGNORED, ord(c)))
|
|
251
|
+
if kept == "":
|
|
252
|
+
return None # nothing left: an empty host
|
|
253
|
+
if not all(_known(c) for c in kept):
|
|
254
|
+
return host # a code point this Python's Unicode does not know: the contextual rules are not read
|
|
255
|
+
mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", kept).casefold()))
|
|
256
|
+
for label in _IDNA_DOTS.split(mapped):
|
|
257
|
+
first = label[:1]
|
|
258
|
+
if first and first not in "\u17b4\u17b5" and _U32.category(first)[0] == "M" and unicodedata.category(first)[0] == "M":
|
|
259
|
+
return None # a label opening with a mark (U+17B4 and U+17B5 are ignored by the newer Nodes)
|
|
260
|
+
if any(c in _JOINERS and _joiner_refused(label, i) for i, c in enumerate(label)):
|
|
261
|
+
return None
|
|
262
|
+
if _bidi_refused(label):
|
|
263
|
+
return None
|
|
264
|
+
return _IDNA_DOTS.sub(".", mapped)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _ipv4_number(part: str) -> Optional[int]:
|
|
268
|
+
if part == "":
|
|
269
|
+
return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
|
|
270
|
+
base = 10
|
|
271
|
+
if part[:2].lower() == "0x":
|
|
272
|
+
part, base = part[2:], 16
|
|
273
|
+
elif len(part) > 1 and part[0] == "0":
|
|
274
|
+
part, base = part[1:], 8
|
|
275
|
+
if part == "":
|
|
276
|
+
return 0
|
|
277
|
+
digits = {10: "0123456789", 16: "0123456789abcdefABCDEF", 8: "01234567"}[base]
|
|
278
|
+
return int(part, base) if all(c in digits for c in part) else None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _domain_ok(host: str) -> bool:
|
|
282
|
+
"""The WHATWG host parser on a special scheme's host, enough to know when `new URL` refuses it."""
|
|
283
|
+
if host.startswith("["):
|
|
284
|
+
return host.endswith("]") and _ipv6_ok(host[1:-1])
|
|
285
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", host) is None:
|
|
286
|
+
try:
|
|
287
|
+
host = urllib.parse.unquote_to_bytes(host).decode("utf-8")
|
|
288
|
+
except UnicodeDecodeError:
|
|
289
|
+
return False
|
|
290
|
+
if any(c in _FORBIDDEN_DOMAIN for c in host):
|
|
291
|
+
return False
|
|
292
|
+
# A non-ASCII host goes through UTS46 in WHATWG; this module reads it narrowly (_idna_host, FAA-98). When unsure,
|
|
293
|
+
# the host is taken as valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
|
|
294
|
+
# (round-2 reading). A host UTS46 maps to ASCII is read by the ASCII rules below (`127.0.0.1` is an address).
|
|
295
|
+
if any(ord(c) > 0x7f for c in host):
|
|
296
|
+
mapped = _idna_host(host)
|
|
297
|
+
if mapped is None:
|
|
298
|
+
return False
|
|
299
|
+
if not mapped.isascii() or any(c in _FORBIDDEN_DOMAIN for c in mapped):
|
|
300
|
+
return True
|
|
301
|
+
host = mapped
|
|
302
|
+
labels = host.split(".")
|
|
303
|
+
if labels and labels[-1] == "" and len(labels) > 1:
|
|
304
|
+
labels = labels[:-1]
|
|
305
|
+
last = labels[-1] if labels else ""
|
|
306
|
+
if not (last and (last.isdigit() and last.isascii() or re.fullmatch(r"0[xX][0-9A-Fa-f]*", last))):
|
|
307
|
+
return host != ""
|
|
308
|
+
numbers = [_ipv4_number(label) for label in labels]
|
|
309
|
+
if len(numbers) > 4 or any(n is None for n in numbers):
|
|
310
|
+
return False
|
|
311
|
+
return all(n <= 255 for n in numbers[:-1]) and numbers[-1] < 256 ** (5 - len(numbers))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _ipv6_ok(text: str) -> bool:
|
|
315
|
+
if "%" in text:
|
|
316
|
+
return False # a zone id: `ipaddress` takes one, WHATWG does not
|
|
317
|
+
try:
|
|
318
|
+
ipaddress.IPv6Address(text)
|
|
319
|
+
return True
|
|
320
|
+
except ValueError:
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
|
|
144
324
|
def _percent_encode(text: str, also: str) -> str:
|
|
145
325
|
"""WHATWG's percent-encoding of a query or a fragment: every C0 control, space, `"`, `<`, `>`, every code point
|
|
146
326
|
above U+007E and the characters in `also`, as UTF-8 %XX (a lone surrogate as U+FFFD, as a USVString reads it).
|
|
@@ -162,8 +342,8 @@ def url_parts(value: str) -> Tuple[str, str, str]:
|
|
|
162
342
|
after every slash or backslash that follows `scheme:` (the special authority ignore slashes state) and ends at the
|
|
163
343
|
next slash, backslash, `?` or `#`, so `<https>:u:p@h` and `<https>:\\u:p@h` carry the password p; any other scheme
|
|
164
344
|
has an authority only after `//`, and `file` has no user information. The user information ends at the authority's
|
|
165
|
-
last `@`, and the password follows its first `:`.
|
|
166
|
-
|
|
345
|
+
last `@`, and the password follows its first `:`. The host is not checked here: secret_url refuses first a URL
|
|
346
|
+
whose host or port `new URL` refuses (url_refused, FAA-309)."""
|
|
167
347
|
scheme, _, rest = value.partition(":")
|
|
168
348
|
rest, _, fragment = rest.partition("#")
|
|
169
349
|
rest, _, query = rest.partition("?")
|
|
@@ -182,10 +362,60 @@ def url_parts(value: str) -> Tuple[str, str, str]:
|
|
|
182
362
|
_url_parts = url_parts # the name before FAA-250, kept for code that reached it
|
|
183
363
|
|
|
184
364
|
|
|
365
|
+
def url_refused(value: str) -> bool:
|
|
366
|
+
"""Whether `new URL` refuses an absolute URL (trimmed as secret_url trims it) for its host or port (FAA-309), read
|
|
367
|
+
with the same authority url_parts reads: a special scheme's host through the WHATWG host parser and the IDNA reading
|
|
368
|
+
of FAA-98 (`_domain_ok`), never empty; `file`'s host, after `//`, the same, empty allowed; another scheme's host,
|
|
369
|
+
after `//`, without a forbidden host code point, and empty only with no user information and no `:` after it (an
|
|
370
|
+
empty port is refused too); a port of ASCII digits up to 65535. Where the reading is unsure, the host is taken as
|
|
371
|
+
valid (the detector's safe side)."""
|
|
372
|
+
value = _LONE_SURROGATE.sub("\ufffd", value)
|
|
373
|
+
scheme, _, rest = value.partition(":")
|
|
374
|
+
rest = rest.partition("#")[0].partition("?")[0]
|
|
375
|
+
lowered = scheme.lower()
|
|
376
|
+
if lowered in _SPECIAL:
|
|
377
|
+
authority = rest.replace("\\", "/").lstrip("/").partition("/")[0]
|
|
378
|
+
elif lowered == "file":
|
|
379
|
+
rest = rest.replace("\\", "/")
|
|
380
|
+
if not rest.startswith("//"):
|
|
381
|
+
return False
|
|
382
|
+
host = rest[2:].partition("/")[0]
|
|
383
|
+
return not (host == "" or re.fullmatch(r"[A-Za-z][:|]", host) or _domain_ok(host))
|
|
384
|
+
elif rest.startswith("//"):
|
|
385
|
+
authority = rest[2:].partition("/")[0]
|
|
386
|
+
else:
|
|
387
|
+
return False
|
|
388
|
+
hostport = authority.rpartition("@")[2]
|
|
389
|
+
if hostport.startswith("["):
|
|
390
|
+
close = hostport.find("]")
|
|
391
|
+
if close < 0:
|
|
392
|
+
return True
|
|
393
|
+
host, after = hostport[:close + 1], hostport[close + 1:]
|
|
394
|
+
if after and not after.startswith(":"):
|
|
395
|
+
return True
|
|
396
|
+
port = after[1:]
|
|
397
|
+
else:
|
|
398
|
+
host, _, port = hostport.partition(":")
|
|
399
|
+
if port and not (port.isascii() and port.isdigit() and int(port) <= 65535):
|
|
400
|
+
return True
|
|
401
|
+
if lowered in _SPECIAL:
|
|
402
|
+
return host == "" or not _domain_ok(host)
|
|
403
|
+
if host.startswith("["):
|
|
404
|
+
return not _ipv6_ok(host[1:-1])
|
|
405
|
+
return any(c in _FORBIDDEN_HOST for c in host) or (host == "" and ("@" in authority or ":" in hostport))
|
|
406
|
+
|
|
407
|
+
|
|
185
408
|
def secret_url(value: str) -> bool:
|
|
186
409
|
"""Whether a URL an MCP entry or a launch names carries a credential (LC-21): anything secret_argument finds, a
|
|
187
410
|
password in its user information, or a query or fragment parameter whose name can only carry a credential and has
|
|
188
|
-
a value, or whose value is shaped like one. A string that is not an absolute URL
|
|
411
|
+
a value, or whose value is shaped like one. A string that is not an absolute URL, or one whose host or port
|
|
412
|
+
`new URL` refuses (url_refused, FAA-309), is checked as an argument only, as the Node twin's secretUrl reads it."""
|
|
413
|
+
return _url_credential(value, hosts=True)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _url_credential(value: str, hosts: bool) -> bool:
|
|
417
|
+
"""secret_url; with `hosts` False the host is not checked, as repository_credential reads a repository's parameters
|
|
418
|
+
whatever its host (the Node twin's repositoryParts)."""
|
|
189
419
|
if secret_argument(value):
|
|
190
420
|
return True
|
|
191
421
|
# What WHATWG URL parsing does first: trim C0 controls and space from the ends (never other Unicode spaces, which it
|
|
@@ -193,6 +423,8 @@ def secret_url(value: str) -> bool:
|
|
|
193
423
|
value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
|
|
194
424
|
if not _SCHEME.match(value):
|
|
195
425
|
return False # not an absolute URL (WHATWG URL parsing refuses it)
|
|
426
|
+
if hosts and url_refused(value):
|
|
427
|
+
return False # `new URL` refuses its host or port, so the Node twin reads it as an argument only (FAA-309)
|
|
196
428
|
password, query, fragment = url_parts(value)
|
|
197
429
|
if password:
|
|
198
430
|
return True
|
|
@@ -260,4 +492,4 @@ def repository_credential(value: str) -> bool:
|
|
|
260
492
|
odd = [piece for written in (path, ascii_decoded(path)) for piece in re.split(r"[/\\:]", written)
|
|
261
493
|
if piece and not repository_name(piece)]
|
|
262
494
|
# the scheme and authority decoded too: git sends `https://%67hp_...@h` with its user decoded (unit reading 12)
|
|
263
|
-
return
|
|
495
|
+
return _url_credential(head + tail, hosts=False) or secret_argument(ascii_decoded(head)) or any(secret_argument(piece) for piece in odd)
|
|
@@ -234,7 +234,7 @@ def _url_secret(url: str, depth: int = 0) -> bool:
|
|
|
234
234
|
if not _real(value):
|
|
235
235
|
continue
|
|
236
236
|
if (_secrets.credential_name(name) and _secrets._long_secret(value)) or \
|
|
237
|
-
_secrets.
|
|
237
|
+
_secrets.has_jwt(value) or URL_SECRET_VALUE.search(value):
|
|
238
238
|
return True
|
|
239
239
|
return False
|
|
240
240
|
|
|
@@ -287,7 +287,7 @@ def _secret_in(line: str) -> Optional[str]:
|
|
|
287
287
|
return "a private key block"
|
|
288
288
|
if any(_real(m.group(1)) for m in JWK_PRIVATE.finditer(line)):
|
|
289
289
|
return "a private key (a JWK's `d`)"
|
|
290
|
-
if any(_real(
|
|
290
|
+
if any(_real(line[a:b]) for a, b in _secrets.jwt_spans(line)): # one pass (FAA-313)
|
|
291
291
|
return "a token shaped like a JWT"
|
|
292
292
|
# The detector reads one configuration value; across a line, `sk-` needs a word start of its own, or every name
|
|
293
293
|
# with a segment ending in `sk` (`task-management-service`) would read as a key.
|
|
@@ -486,7 +486,7 @@ def _secret_spans(text: str) -> List[Tuple[int, int]]:
|
|
|
486
486
|
|
|
487
487
|
def _raw_secret_spans(text: str) -> List[Tuple[int, int]]:
|
|
488
488
|
spans = [m.span() for m in PRIVATE_KEY.finditer(text)]
|
|
489
|
-
spans += [
|
|
489
|
+
spans += [(a, b) for a, b in _secrets.jwt_spans(text) if _real(text[a:b])] # one pass (FAA-313)
|
|
490
490
|
spans += [m.span(1) for m in JWK_PRIVATE.finditer(text) if _real(m.group(1))]
|
|
491
491
|
spans += list(_provider_keys(text))
|
|
492
492
|
spans += [m.span() for m in _overlapping(_secrets._BEARER_INSIDE, text) if _real(m.group(0))]
|
|
@@ -328,20 +328,7 @@ def _credential_in(written: str) -> int:
|
|
|
328
328
|
|
|
329
329
|
def _jwt_spans(text: str) -> List[Tuple[int, int]]:
|
|
330
330
|
"""`jwtSpans`: `JWT_SHAPE`'s leftmost, non-overlapping matches, read as the contract's one pass reads them."""
|
|
331
|
-
|
|
332
|
-
run = re.compile(r"[A-Za-z0-9_-]*")
|
|
333
|
-
at = text.find("eyJ")
|
|
334
|
-
while at >= 0:
|
|
335
|
-
one = run.match(text, at + 3).end()
|
|
336
|
-
start_next = one
|
|
337
|
-
if one - at - 3 >= 2 and text[one:one + 1] == ".":
|
|
338
|
-
two = run.match(text, one + 1).end()
|
|
339
|
-
if two - one - 1 >= 2 and text[two:two + 1] == ".":
|
|
340
|
-
three = run.match(text, two + 1).end()
|
|
341
|
-
spans.append((at, three))
|
|
342
|
-
start_next = three
|
|
343
|
-
at = text.find("eyJ", start_next)
|
|
344
|
-
return spans
|
|
331
|
+
return _secrets.jwt_spans(text)
|
|
345
332
|
|
|
346
333
|
|
|
347
334
|
def credential_at(line: str) -> int:
|
|
@@ -366,7 +353,7 @@ def _credential_url_spans(line: str) -> List[Tuple[int, int]]:
|
|
|
366
353
|
|
|
367
354
|
|
|
368
355
|
def _own_secret_value(text: str) -> bool:
|
|
369
|
-
return bool(_secrets.
|
|
356
|
+
return bool(_secrets.has_jwt(text) or _check.URL_SECRET_VALUE.search(text) or _secrets._long_secret(text))
|
|
370
357
|
|
|
371
358
|
|
|
372
359
|
def credential_shaped(text: str) -> bool:
|
|
@@ -374,7 +361,7 @@ def credential_shaped(text: str) -> bool:
|
|
|
374
361
|
|
|
375
362
|
|
|
376
363
|
def _prose_secret_value(text: str) -> bool:
|
|
377
|
-
return bool(_secrets.
|
|
364
|
+
return bool(_secrets.has_jwt(text) or any(True for _ in _check._provider_keys(text))
|
|
378
365
|
or _secrets._BEARER_INSIDE.search(text) or _secrets._long_secret(text))
|
|
379
366
|
|
|
380
367
|
|
|
@@ -9,7 +9,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
9
9
|
compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
10
10
|
metadata:
|
|
11
11
|
author: PassionCode.ai
|
|
12
|
-
version: "0.
|
|
12
|
+
version: "0.82.0"
|
|
13
13
|
contract-version: "0.1.0"
|
|
14
14
|
extension: "fabric-service/0.1"
|
|
15
15
|
extension-commit: "93e7db164c7829e2783b4b7705c4113bb5201131"
|
|
@@ -310,7 +310,8 @@ reference's *A2A for remote agents* section), `channel-agent.declaration` (DEC-0
|
|
|
310
310
|
`board.responder` for a service that declares the project board) and exits 1 on any `FAIL`. A `NOT_RUN` is not a pass;
|
|
311
311
|
name it in the report. A service with a dashboard also runs the layout probe,
|
|
312
312
|
`node <skill-dir>/scripts/check_dashboard_layout.mjs --service <id>` (WebKit at 360, 480, 768
|
|
313
|
-
and 1280 px; needs Playwright, else `NOT_RUN` and exit 3: nothing was measured
|
|
313
|
+
and 1280 px; needs Playwright, else `NOT_RUN` and exit 3: nothing was measured; also `NOT_RUN`
|
|
314
|
+
and exit 3 where the socket table is restricted, `table-restricted`, since 0.81.0: FAA-318).
|
|
314
315
|
|
|
315
316
|
Then prove the lock: start a second copy by hand against the same data directory; it
|
|
316
317
|
must exit 75 while the first keeps serving. Prove recovery: `kill -9` the launchd pid;
|