@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +5 -3
  3. package/SECURITY.md +4 -2
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +9 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +239 -7
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +3 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +22 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +6 -3
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +87 -20
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +29 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +1 -1
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +12 -4
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +3 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -138
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +239 -7
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +117 -19
  47. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  48. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
  49. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
  50. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  51. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  52. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
  53. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
  54. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  55. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
  56. 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), answers what waits on its
27
- Project in the same turn with its state (the result later; a request its Project's service should take stays queued; before Fabric 0.3.17 a run Fabric starts
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), and asks other
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 (a more specific bind by root over a wildcard job would pass; the pin's DEC-0155 RT-3 vouches only for exactly `127.0.0.1`, a departure FAA-258 filed). A LaunchDaemon's root
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). The browser then connects on its own: a listener swapped
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.81.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.81.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.81.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 native link does not take it: Fabric
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
@@ -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 _secrets.secret_url(connection[field]):
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 _secrets.secret_url(entry["url"])):
722
+ if any(isinstance(a, str) and _secrets.secret_argument(a) for a in argv) or (isinstance(entry.get("url"), str) and _stored_url_credential(entry["url"])):
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(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or long_secret)
79
+ return bool(has_jwt(value) or SECRET_VALUE.search(value) or long_secret)
62
80
 
63
81
 
64
82
  def carries_credential(text):
@@ -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(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _long_secret(value))
117
+ return bool(has_jwt(value) or SECRET_VALUE.search(value) or _long_secret(value))
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 JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
129
+ if has_jwt(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
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 `:`. A host `new URL` would refuse is not checked: such a URL is read
166
- for its password and parameters all the same, the safe direction for a detector."""
345
+ last `@`, and the password follows its first `:`. The host is not checked here: secret_url refuses first a URL
346
+ whose host or port `new URL` refuses (url_refused, FAA-309)."""
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 is checked as an argument only."""
411
+ a value, or whose value is shaped like one. A string that is not an absolute URL, or one whose host or port
412
+ `new URL` refuses (url_refused, FAA-309), is checked as an argument only, as the Node twin's secretUrl reads it."""
413
+ return _url_credential(value, hosts=True)
414
+
415
+
416
+ def _url_credential(value: str, hosts: bool) -> bool:
417
+ """secret_url; with `hosts` False the host is not checked, as repository_credential reads a repository's parameters
418
+ whatever its host (the Node twin's repositoryParts)."""
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 secret_url(head + tail) or secret_argument(ascii_decoded(head)) or any(secret_argument(piece) for piece in odd)
495
+ return _url_credential(head + tail, hosts=False) or secret_argument(ascii_decoded(head)) or any(secret_argument(piece) for piece in odd)
@@ -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.JWT_SHAPE.search(value) or URL_SECRET_VALUE.search(value):
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(m.group(0)) for m in _secrets.JWT_SHAPE.finditer(line)):
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 += [m.span() for m in _secrets.JWT_SHAPE.finditer(text) if _real(m.group(0))]
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
- spans = []
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.JWT_SHAPE.search(text) or _check.URL_SECRET_VALUE.search(text) or _secrets._long_secret(text))
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.JWT_SHAPE.search(text) or any(True for _ in _check._provider_keys(text))
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.81.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;