@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.84.1
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 +169 -3
- package/README.md +7 -3
- package/SECURITY.md +4 -3
- 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 +7 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +1 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +38 -12
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +28 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +13 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +11 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +275 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +55 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +47 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +25 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +27 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +19 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +7 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +22 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +4 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +21 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +16 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +25 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +456 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +18 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +19 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +56 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +16 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +181 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +20 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +469 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +405 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +17 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +98 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +37 -48
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +25 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +28 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +19 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +24 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +32 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +19 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +10 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +56 -96
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +99 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +16 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +44 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +648 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +17 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +108 -108
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +38 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +391 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +19 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +493 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +632 -104
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +17 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +6 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +182 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
2
3
|
"""The contract's one credential detector (Fabric Agent Contract DEC-0044, `src/secret-shape.ts` at the pin): a name
|
|
3
4
|
that can only carry a credential, a value shaped like one, a launch argument that carries one, and a URL that does
|
|
4
5
|
(LC-21). A port held to the contract's own answers by test/fixtures/contract/profile-vectors.json. Standard library
|
|
@@ -31,7 +32,7 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
|
|
|
31
32
|
}
|
|
32
33
|
#: ECMAScript's \s, as a character class body.
|
|
33
34
|
JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
34
|
-
|
|
35
|
+
NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
|
|
35
36
|
JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
|
|
36
37
|
_URL_BASE64_RUN = re.compile(r"[A-Za-z0-9_-]*")
|
|
37
38
|
|
|
@@ -74,14 +75,17 @@ _PROVIDER_KEYS = (r"(?:sk-(?=[A-Za-z0-9_-]{0,255}?[0-9])(?:(?=[A-Za-z0-9_-]{0,25
|
|
|
74
75
|
#: The contract's SECRET_VALUE at the pin (DEC-0151): `Bearer ` at a value's start in any case, or a provider key after a
|
|
75
76
|
#: boundary. No case-insensitive flag; digits are ASCII. A match includes its boundary character: callers test, never
|
|
76
77
|
#: read the matched text or its index.
|
|
77
|
-
SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" +
|
|
78
|
+
SECRET_VALUE = re.compile("(^[Bb][Ee][Aa][Rr][Ee][Rr][" + JS_SPACE + "]" + NOT_LINE_END + "+|" + _KEY_BOUNDARY + _PROVIDER_KEYS + ")")
|
|
78
79
|
_LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
|
|
79
|
-
|
|
80
|
+
BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
|
|
80
81
|
re.IGNORECASE | re.ASCII)
|
|
81
82
|
_PATH_LIKE = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE | re.ASCII)
|
|
82
83
|
_WORD_SPLIT = re.compile("[%s._-]+" % JS_SPACE)
|
|
83
84
|
_ARGUMENT_SPLIT = re.compile("[%s=]" % JS_SPACE)
|
|
84
|
-
|
|
85
|
+
SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
86
|
+
#: The names the check and older readers wrote before these became public (FAA-190 (2), (12)): kept so a module from
|
|
87
|
+
#: an earlier version beside this one still loads.
|
|
88
|
+
_BEARER_INSIDE, _SCHEME, _NOT_LINE_END = BEARER_INSIDE, SCHEME, NOT_LINE_END
|
|
85
89
|
#: WHATWG's special schemes that carry an authority with user information (file has none).
|
|
86
90
|
_SPECIAL = ("ftp", "http", "https", "ws", "wss")
|
|
87
91
|
|
|
@@ -108,13 +112,17 @@ def credential_name(name: str) -> bool:
|
|
|
108
112
|
return any(word in words and any(p in words for p in partners) for word, partners in CREDENTIAL_PAIRS.items())
|
|
109
113
|
|
|
110
114
|
|
|
111
|
-
def
|
|
115
|
+
def long_secret(value: str) -> bool:
|
|
116
|
+
"""A run of 40 or more base64 characters mixing upper case, lower case and digits (the Node twin's longSecret)."""
|
|
112
117
|
return any(re.search(r"[a-z]", run) and re.search(r"[A-Z]", run) and re.search(r"[0-9]", run) for run in _LONG_RUN.findall(value))
|
|
113
118
|
|
|
114
119
|
|
|
120
|
+
_long_secret = long_secret # its name before FAA-190 (2)
|
|
121
|
+
|
|
122
|
+
|
|
115
123
|
def secret_value(value: str) -> bool:
|
|
116
124
|
"""A JWT anywhere, a provider key or `Bearer …`, or a long base64 run mixing upper case, lower case and digits."""
|
|
117
|
-
return bool(has_jwt(value) or SECRET_VALUE.search(value) or
|
|
125
|
+
return bool(has_jwt(value) or SECRET_VALUE.search(value) or long_secret(value))
|
|
118
126
|
|
|
119
127
|
|
|
120
128
|
def _path_like(piece: str) -> bool:
|
|
@@ -126,13 +134,13 @@ def _path_like(piece: str) -> bool:
|
|
|
126
134
|
def secret_argument(value: str) -> bool:
|
|
127
135
|
"""The same for a launch argument or a server's command and URL: a long run is looked for between path separators,
|
|
128
136
|
so a folder path holding a session id is not taken for a key."""
|
|
129
|
-
if has_jwt(value) or SECRET_VALUE.search(value) or
|
|
137
|
+
if has_jwt(value) or SECRET_VALUE.search(value) or BEARER_INSIDE.search(value):
|
|
130
138
|
return True
|
|
131
139
|
for piece in _ARGUMENT_SPLIT.split(value):
|
|
132
140
|
if _path_like(piece):
|
|
133
|
-
if any(
|
|
141
|
+
if any(long_secret(part) for part in re.split(r"[\\/]", piece)):
|
|
134
142
|
return True
|
|
135
|
-
elif
|
|
143
|
+
elif long_secret(piece):
|
|
136
144
|
return True
|
|
137
145
|
return False
|
|
138
146
|
|
|
@@ -421,7 +429,7 @@ def _url_credential(value: str, hosts: bool) -> bool:
|
|
|
421
429
|
# What WHATWG URL parsing does first: trim C0 controls and space from the ends (never other Unicode spaces, which it
|
|
422
430
|
# refuses), then remove every tab, CR and LF.
|
|
423
431
|
value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
|
|
424
|
-
if not
|
|
432
|
+
if not SCHEME.match(value):
|
|
425
433
|
return False # not an absolute URL (WHATWG URL parsing refuses it)
|
|
426
434
|
if hosts and url_refused(value):
|
|
427
435
|
return False # `new URL` refuses its host or port, so the Node twin reads it as an argument only (FAA-309)
|
|
@@ -442,7 +450,25 @@ _REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
|
|
|
442
450
|
#: boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
|
|
443
451
|
_REPOSITORY_KEYS = r"sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}"
|
|
444
452
|
_KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % _REPOSITORY_KEYS)
|
|
445
|
-
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def jwt_at_a_word(text: str) -> bool:
|
|
456
|
+
"""Whether JWT_SHAPE matches at a word's start: the text's start, or after a `.`, `_` or `-` (FAA-321). Read in one
|
|
457
|
+
pass, as jwt_spans: the look-behind expression backtracked from every such `eyJ` of a long run, quadratic in it.
|
|
458
|
+
Every `eyJ` of one run shares that run's end and a shorter first segment, so only a run's first `eyJ` at a word's
|
|
459
|
+
start is tried; an `eyJ` that is not at one is passed over, since a later one of its run may be."""
|
|
460
|
+
at = text.find("eyJ")
|
|
461
|
+
while at >= 0:
|
|
462
|
+
if at and text[at - 1] not in "._-":
|
|
463
|
+
at = text.find("eyJ", at + 1)
|
|
464
|
+
continue
|
|
465
|
+
one = _URL_BASE64_RUN.match(text, at + 3).end()
|
|
466
|
+
if one - at - 3 >= 2 and text[one:one + 1] == ".":
|
|
467
|
+
two = _URL_BASE64_RUN.match(text, one + 1).end()
|
|
468
|
+
if two - one - 1 >= 2 and text[two:two + 1] == ".":
|
|
469
|
+
return True
|
|
470
|
+
at = text.find("eyJ", one)
|
|
471
|
+
return False
|
|
446
472
|
|
|
447
473
|
|
|
448
474
|
def repository_name(piece: str) -> bool:
|
|
@@ -450,7 +476,7 @@ def repository_name(piece: str) -> bool:
|
|
|
450
476
|
key's or JWT's shape (`sk-`, `ghp_`, `xoxb-`, `AKIA`, `eyJ`) at its start or after a `.`, `_` or `-` (0.62.0
|
|
451
477
|
readings 9 and 10), so a letters-only key is still a key while `task-pipeline-dashboard`, which holds an `sk-` shape
|
|
452
478
|
inside a word, is a name."""
|
|
453
|
-
return bool(_REPOSITORY_NAME.match(piece)) and not _KEY_AT_A_WORD.search(piece) and not
|
|
479
|
+
return bool(_REPOSITORY_NAME.match(piece)) and not _KEY_AT_A_WORD.search(piece) and not jwt_at_a_word(piece)
|
|
454
480
|
|
|
455
481
|
|
|
456
482
|
def ascii_decoded(text: str) -> str:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
1
2
|
"""Reference kit for the fabric-service/0.1 local service extension: the facade.
|
|
2
3
|
|
|
3
4
|
Since 0.40.0 (FAA-65) the kit is a set of modules, and this file re-exports every name it defined at 0.39.1, so
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
2
|
+
"""fabric_socket_proof: this OS account's own TCP sockets, read from its own processes (layer L0; DEC-0155 RT-2,
|
|
3
|
+
FAA-292; FAA-272).
|
|
4
|
+
|
|
5
|
+
macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew or uv CPython) only its own sockets in the host's socket
|
|
6
|
+
table, so `fabric_peer_owner` reads such a table as `table-restricted` and cannot tell who accepted a connection. The
|
|
7
|
+
question can still be asked of this account's own processes, which the restriction leaves readable: whether one of them
|
|
8
|
+
holds the very socket that accepted the connection (RT-2, the accepted-socket proof), or a listener bound exactly to
|
|
9
|
+
127.0.0.1:<port> (HA-2's macOS row, FAA-272). Another account's process answers EPERM and is skipped, so a proof only
|
|
10
|
+
ever says "this account's" or "not proved"; never another account's, the system's or nobody's.
|
|
11
|
+
|
|
12
|
+
accepted(server, client, wait=...) -> Proof the socket at `server` connected to `client` is in one of this
|
|
13
|
+
account's processes (`wait`: read again for ACCEPT_WAIT_SECONDS
|
|
14
|
+
until the server has called accept())
|
|
15
|
+
own_listener(port) -> Proof a listener of this account on exactly 127.0.0.1:<port>
|
|
16
|
+
|
|
17
|
+
Two readers, chosen by `method()`:
|
|
18
|
+
|
|
19
|
+
- `libproc` (Python's): `proc_listpids(PROC_UID_ONLY)`, `proc_pidinfo(PROC_PIDLISTFDS)` and
|
|
20
|
+
`proc_pidfdinfo(PROC_PIDFDSOCKETINFO)` through ctypes, the records `lsof` itself reads, without a subprocess:
|
|
21
|
+
Project Observatory's `peer_owner` 1.3.1 reader (sha256 `b3831e5f...`, FAA-292). The `socket_fdinfo` layout is not
|
|
22
|
+
an API, so the first read in a process calibrates it on a connected pair of its own, as the sysctl reader does; a
|
|
23
|
+
layout that fails is never used again in this process, and a calibration that could not read at all is asked again.
|
|
24
|
+
- `lsof` (the Node twin's, and Python's when libproc is unavailable or uncalibrated): `lsof -nP -a -u <uid>
|
|
25
|
+
-iTCP:<port> -sTCP:<state> -F pun`, the exact address pair and the numeric uid. The twins agree on answers, not on
|
|
26
|
+
the program they run (DEC-0155 Compatibility); `test/fixtures/peer-owner/socket-proof-cases.json` holds both.
|
|
27
|
+
|
|
28
|
+
The proof is weaker than a table's `so_uid`: "a process of this uid holds the descriptor", not "the socket's
|
|
29
|
+
credential is this uid". A socket another account accepted and passed with SCM_RIGHTS to a process of this uid would
|
|
30
|
+
read as this account's; a caller that knows the server's pid closes that with `expect_pid`. One socket several of this
|
|
31
|
+
account's processes hold (an inherited descriptor) is proved with pid None, reason `several-processes`. A process whose
|
|
32
|
+
real and effective uids differ is refused (`setuid-context`): XNU lists and checks processes by the effective uid.
|
|
33
|
+
Reasons, as DEC-0155 RT-1 spells them: `table-restricted` (read, and no socket of this account matches) or
|
|
34
|
+
`table-restricted: <detail>` (the proof could not be read). Bounded by MAX_PIDS, MAX_FDS and TIMEOUT_SECONDS; nothing
|
|
35
|
+
is cached across questions but the calibration verdict. Standard library only, no other kit module. Tests:
|
|
36
|
+
`test/test_fabric_socket_proof.py`; the Node twin is `fabric-socket-proof.mjs`.
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import ipaddress
|
|
41
|
+
import os
|
|
42
|
+
import socket
|
|
43
|
+
import struct
|
|
44
|
+
import subprocess
|
|
45
|
+
import sys
|
|
46
|
+
import threading
|
|
47
|
+
import time
|
|
48
|
+
from typing import Callable, List, NamedTuple, Optional, Tuple
|
|
49
|
+
|
|
50
|
+
TABLE_RESTRICTED = "table-restricted"
|
|
51
|
+
LIBPROC = "libproc"
|
|
52
|
+
LSOF_METHOD = "lsof"
|
|
53
|
+
LSOF = "/usr/sbin/lsof"
|
|
54
|
+
#: Every read of one question is over within this many seconds (the accept wait included).
|
|
55
|
+
TIMEOUT_SECONDS = 2.0
|
|
56
|
+
#: How long `accepted(..., wait=True)` reads again for a server that has not called accept() yet.
|
|
57
|
+
ACCEPT_WAIT_SECONDS = 0.5
|
|
58
|
+
MAX_PIDS = 16384
|
|
59
|
+
MAX_FDS = 65536
|
|
60
|
+
MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
61
|
+
|
|
62
|
+
#: macOS `sys/proc_info.h`: selectors and the `struct socket_fdinfo` offsets read here. The struct is 792 bytes:
|
|
63
|
+
#: `proc_fileinfo` (24), then `socket_info`, whose `soi_so` is the kernel's socket (the same in every process that
|
|
64
|
+
#: holds it) and whose `soi_proto.pri_tcp` starts with an `in_sockinfo` (ports as ints holding a network-order port,
|
|
65
|
+
#: `insi_vflag`, the foreign and local `in4in6_addr`) and carries `tcpsi_state`.
|
|
66
|
+
PROC_UID_ONLY = 4
|
|
67
|
+
PROC_PIDLISTFDS = 1
|
|
68
|
+
PROX_FDTYPE_SOCKET = 2
|
|
69
|
+
PROC_PIDFDSOCKETINFO = 3
|
|
70
|
+
SOCKINFO_TCP = 2
|
|
71
|
+
SOCKET_FDINFO_SIZE = 792
|
|
72
|
+
_SOI = 24 # socket_fdinfo.psi
|
|
73
|
+
_SOI_SO = _SOI + 136 # socket_info.soi_so
|
|
74
|
+
_SOI_KIND = _SOI + 232 # socket_info.soi_kind
|
|
75
|
+
_INI = _SOI + 240 # soi_proto.pri_tcp.tcpsi_ini
|
|
76
|
+
_INI_VFLAG = _INI + 24 # INI_IPV4 0x1, INI_IPV6 0x2
|
|
77
|
+
_INI_FOREIGN = _INI + 32
|
|
78
|
+
_INI_LOCAL = _INI + 48
|
|
79
|
+
_TCPSI_STATE = _INI + 80 # TSI_S_CLOSED 0, TSI_S_LISTEN 1, TSI_S_TIME_WAIT 10
|
|
80
|
+
_TSI_S_CLOSED = 0
|
|
81
|
+
_TSI_S_LISTEN = 1
|
|
82
|
+
_TSI_S_TIME_WAIT = 10
|
|
83
|
+
|
|
84
|
+
Address = Tuple[str, int]
|
|
85
|
+
# Real types, not postponed strings (as fabric_peer_owner's Owner): a NamedTuple's generated __new__ resolves its
|
|
86
|
+
# annotations in a namespace of its own, where `Address` is no name.
|
|
87
|
+
_SocketFields = NamedTuple("_SocketFields", [("local", Tuple[str, int]), ("remote", Tuple[str, int]),
|
|
88
|
+
("listening", bool), ("pid", Optional[int])])
|
|
89
|
+
_ProofFields = NamedTuple("_ProofFields", [("proved", bool), ("pid", Optional[int]), ("method", str),
|
|
90
|
+
("reason", Optional[str])])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Socket(_SocketFields):
|
|
94
|
+
"""One TCP socket of this account: `pid` is None when several of its processes hold it."""
|
|
95
|
+
__slots__ = ()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Proof(_ProofFields):
|
|
99
|
+
"""Whether this account's process holds the socket asked about; `reason` says why not, or `several-processes`."""
|
|
100
|
+
__slots__ = ()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ProofUnreadable(Exception):
|
|
104
|
+
"""This account's sockets could not be read; the message is the reason, never a socket's content."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _host(host: str) -> str:
|
|
108
|
+
"""One spelling per address, as fabric_peer_owner.norm_host (the same rule, kept here so this module stands alone):
|
|
109
|
+
an IPv4-mapped IPv6 address is its IPv4 form, an unspecified one `*`, `localhost` 127.0.0.1, and `[...]` loses its
|
|
110
|
+
brackets. Anything unparseable is returned unchanged (and matches only itself)."""
|
|
111
|
+
if host.startswith("[") and host.endswith("]"):
|
|
112
|
+
host = host[1:-1]
|
|
113
|
+
if host in ("*", ""):
|
|
114
|
+
return "*"
|
|
115
|
+
if host == "localhost":
|
|
116
|
+
return "127.0.0.1"
|
|
117
|
+
host = host.split("%", 1)[0]
|
|
118
|
+
try:
|
|
119
|
+
ip = ipaddress.ip_address(host)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return host
|
|
122
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
123
|
+
ip = ip.ipv4_mapped
|
|
124
|
+
return "*" if ip.is_unspecified else str(ip)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _digits(text: str) -> bool:
|
|
128
|
+
"""`[0-9]+`, as the Node twin reads a number (`str.isdigit` also takes `٤`)."""
|
|
129
|
+
return text != "" and text.isascii() and text.isdigit()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def loopback_host(host: str) -> bool:
|
|
133
|
+
"""Whether a URL's host, as a URL parser leaves it (urlsplit's `hostname`, lower case, no brackets), names this
|
|
134
|
+
computer's loopback the way a WHATWG parser reads it: `localhost` or a name under `.localhost` (which resolves to
|
|
135
|
+
loopback), ::1, or an IPv4 address in 127/8 in any form `new URL` reads (`127.1`, `2130706433`, `0x7f.1`, one
|
|
136
|
+
trailing dot). Never an IPv4-mapped address (Node writes it in hex and refuses it), one with a zone, or whitespace.
|
|
137
|
+
The one rule fabric_http's check_url and fabric_delegation's key set source read (FAA-187 (15)); the Node twin's
|
|
138
|
+
`loopbackHost` reads `new URL`'s hostname."""
|
|
139
|
+
host = (host or "").lower()
|
|
140
|
+
if host == "localhost" or host.endswith(".localhost"):
|
|
141
|
+
return True
|
|
142
|
+
if not host or any(c.isspace() for c in host) or "%" in host:
|
|
143
|
+
return False
|
|
144
|
+
try:
|
|
145
|
+
address = ipaddress.ip_address(host)
|
|
146
|
+
return address.is_loopback and not getattr(address, "ipv4_mapped", None)
|
|
147
|
+
except ValueError:
|
|
148
|
+
pass
|
|
149
|
+
host = host[:-1] if host.endswith(".") else host
|
|
150
|
+
try: # the short IPv4 forms a WHATWG URL also reads (127.1, 2130706433, 0x7f.1)
|
|
151
|
+
return socket.inet_aton(host)[0] == 127
|
|
152
|
+
except (OSError, ValueError):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# -- parsers: pure functions over captured records and text (test/fixtures/peer-owner/socket-proof-cases.json) -------
|
|
157
|
+
|
|
158
|
+
def parse_socket_fdinfo(blob: bytes) -> Optional[Tuple[Address, Address, int, int]]:
|
|
159
|
+
"""One `proc_pidfdinfo(PROC_PIDFDSOCKETINFO)` record: (local, remote, TCP state, socket handle) for a TCP socket,
|
|
160
|
+
None for any other kind. A short record is unreadable."""
|
|
161
|
+
if len(blob) < SOCKET_FDINFO_SIZE:
|
|
162
|
+
raise ProofUnreadable("libproc: short socket record")
|
|
163
|
+
if struct.unpack_from("<i", blob, _SOI_KIND)[0] != SOCKINFO_TCP:
|
|
164
|
+
return None
|
|
165
|
+
fport, lport = struct.unpack_from(">H", blob, _INI)[0], struct.unpack_from(">H", blob, _INI + 4)[0]
|
|
166
|
+
family, at = (socket.AF_INET, 12) if blob[_INI_VFLAG] & 0x1 else (socket.AF_INET6, 0)
|
|
167
|
+
size = 4 if family == socket.AF_INET else 16
|
|
168
|
+
foreign = socket.inet_ntop(family, bytes(blob[_INI_FOREIGN + at:_INI_FOREIGN + at + size]))
|
|
169
|
+
local = socket.inet_ntop(family, bytes(blob[_INI_LOCAL + at:_INI_LOCAL + at + size]))
|
|
170
|
+
state = struct.unpack_from("<i", blob, _TCPSI_STATE)[0]
|
|
171
|
+
return (_host(local), lport), (_host(foreign), fport), state, struct.unpack_from("<Q", blob, _SOI_SO)[0]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def own_sockets(records: List[Tuple[int, Address, Address, int, int]]) -> List[Socket]:
|
|
175
|
+
"""Sockets from this account's records (pid, local, remote, state, handle): one per handle, pid None when several
|
|
176
|
+
processes hold it. Closed and TIME_WAIT sockets are left out."""
|
|
177
|
+
by_handle: dict = {}
|
|
178
|
+
for pid, local, remote, state, handle in records:
|
|
179
|
+
if state in (_TSI_S_CLOSED, _TSI_S_TIME_WAIT):
|
|
180
|
+
continue
|
|
181
|
+
by_handle.setdefault(handle, (local, remote, state, []))[3].append(pid)
|
|
182
|
+
return [Socket(local, remote, state == _TSI_S_LISTEN, pids[0] if len(set(pids)) == 1 else None)
|
|
183
|
+
for local, remote, state, pids in by_handle.values()]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _lsof_address(text: str) -> Optional[Address]:
|
|
187
|
+
"""`127.0.0.1:47311`, `[::1]:47070` or `*:47312`: the port after the last colon, ASCII digits only."""
|
|
188
|
+
host, sep, port = text.rpartition(":")
|
|
189
|
+
if not sep or not _digits(port) or int(port) > 65535:
|
|
190
|
+
return None
|
|
191
|
+
return _host(host), int(port)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def parse_lsof_sockets(text: str, uid: int) -> List[Socket]:
|
|
195
|
+
"""`lsof -nP -a -u <uid> -iTCP:<port> -sTCP:<state> -F pun`: the `n` lines of every process block whose `u` line is
|
|
196
|
+
exactly `uid` (a block of another uid, or with no uid, is not read). A name with `->` is a connection, one without a
|
|
197
|
+
listener. The same socket listed by several processes (one address pair: TCP holds one connection per pair) is one
|
|
198
|
+
socket with pid None."""
|
|
199
|
+
found: dict = {}
|
|
200
|
+
pid = owner = None
|
|
201
|
+
for line in text.splitlines():
|
|
202
|
+
tag, value = line[:1], line[1:]
|
|
203
|
+
if tag == "p":
|
|
204
|
+
pid, owner = (int(value) if _digits(value) else None), None
|
|
205
|
+
elif tag == "u":
|
|
206
|
+
owner = int(value) if _digits(value) else None
|
|
207
|
+
elif tag == "n" and pid is not None and owner == uid:
|
|
208
|
+
local_text, arrow, remote_text = value.partition("->")
|
|
209
|
+
local = _lsof_address(local_text)
|
|
210
|
+
remote = _lsof_address(remote_text) if arrow else ("*", 0)
|
|
211
|
+
if local is None or remote is None:
|
|
212
|
+
continue
|
|
213
|
+
key = (local, remote, not arrow)
|
|
214
|
+
found.setdefault(key, []).append(pid)
|
|
215
|
+
return [Socket(local, remote, listening, pids[0] if len(set(pids)) == 1 else None)
|
|
216
|
+
for (local, remote, listening), pids in found.items()]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _proved(hits: List[Socket], how: str) -> Proof:
|
|
220
|
+
if not hits:
|
|
221
|
+
return Proof(False, None, how, TABLE_RESTRICTED)
|
|
222
|
+
pids = {s.pid for s in hits}
|
|
223
|
+
if len(pids) == 1 and None not in pids:
|
|
224
|
+
return Proof(True, pids.pop(), how, None)
|
|
225
|
+
return Proof(True, None, how, "several-processes")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def accepted_in(sockets: List[Socket], server: Address, client: Address, how: str) -> Proof:
|
|
229
|
+
"""RT-2 over this account's sockets: the one at `server` connected to `client`. Two different sockets with that
|
|
230
|
+
address pair cannot be told apart (`several-rows`)."""
|
|
231
|
+
hits = [s for s in sockets if not s.listening and s.local == server and s.remote == client]
|
|
232
|
+
if len(hits) > 1:
|
|
233
|
+
return Proof(False, None, how, "several-rows")
|
|
234
|
+
return _proved(hits, how)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def listener_in(sockets: List[Socket], port: int, how: str) -> Proof:
|
|
238
|
+
"""FAA-272 over this account's sockets: a listener bound exactly to 127.0.0.1:<port>. XNU refuses another ordinary
|
|
239
|
+
account that bind beside ours (`in_pcbbind`); our wildcard or ::1 listener, and root's, cannot be told from a
|
|
240
|
+
hidden one, so they prove nothing (DEC-0155 RT-4)."""
|
|
241
|
+
return _proved([s for s in sockets if s.listening and s.local == ("127.0.0.1", port)], how)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# -- readers ----------------------------------------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
_LOCK = threading.Lock()
|
|
247
|
+
#: None until a calibration of the libproc layout reaches a verdict in this process, then True or False.
|
|
248
|
+
_LIBPROC_OK: list = [None]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _run(argv: List[str]) -> str:
|
|
252
|
+
"""One bounded run of lsof; its exit 1 with nothing printed is "no socket matched", not a failure."""
|
|
253
|
+
try:
|
|
254
|
+
done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT_SECONDS, check=False, stdin=subprocess.DEVNULL)
|
|
255
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
256
|
+
raise ProofUnreadable("lsof: %s" % type(exc).__name__) from None
|
|
257
|
+
if done.returncode == 1 and not done.stdout.strip():
|
|
258
|
+
return ""
|
|
259
|
+
if done.returncode != 0:
|
|
260
|
+
raise ProofUnreadable("lsof: exit %d" % done.returncode)
|
|
261
|
+
if len(done.stdout) > MAX_OUTPUT_BYTES:
|
|
262
|
+
raise ProofUnreadable("lsof: output over %d bytes" % MAX_OUTPUT_BYTES)
|
|
263
|
+
return done.stdout.decode("utf-8", "replace")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _libproc_records(uid: int, deadline: float) -> List[Tuple[int, Address, Address, int, int]]:
|
|
267
|
+
"""This uid's processes' TCP sockets, as (pid, local, remote, state, handle). A process that cannot be read (gone,
|
|
268
|
+
or EPERM) is skipped: it can only lose an answer, never make one."""
|
|
269
|
+
try:
|
|
270
|
+
import ctypes
|
|
271
|
+
lib = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True)
|
|
272
|
+
lib.proc_listpids.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_int]
|
|
273
|
+
lib.proc_pidinfo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint64, ctypes.c_void_p, ctypes.c_int]
|
|
274
|
+
lib.proc_pidfdinfo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_int]
|
|
275
|
+
except (ImportError, OSError, TypeError, AttributeError) as exc:
|
|
276
|
+
raise ProofUnreadable("libproc: %s" % type(exc).__name__) from None
|
|
277
|
+
for fn in (lib.proc_listpids, lib.proc_pidinfo, lib.proc_pidfdinfo):
|
|
278
|
+
fn.restype = ctypes.c_int
|
|
279
|
+
need = lib.proc_listpids(PROC_UID_ONLY, uid, None, 0)
|
|
280
|
+
if need <= 0:
|
|
281
|
+
raise ProofUnreadable("libproc: errno %d" % ctypes.get_errno())
|
|
282
|
+
pids = (ctypes.c_int * min(need // 4 + 64, MAX_PIDS))()
|
|
283
|
+
got = lib.proc_listpids(PROC_UID_ONLY, uid, pids, ctypes.sizeof(pids))
|
|
284
|
+
if got <= 0:
|
|
285
|
+
raise ProofUnreadable("libproc: errno %d" % ctypes.get_errno())
|
|
286
|
+
if got // 4 >= MAX_PIDS:
|
|
287
|
+
raise ProofUnreadable("libproc: over %d processes" % MAX_PIDS)
|
|
288
|
+
record = ctypes.create_string_buffer(SOCKET_FDINFO_SIZE)
|
|
289
|
+
out = []
|
|
290
|
+
for pid in pids[:got // 4]:
|
|
291
|
+
if pid <= 0:
|
|
292
|
+
continue
|
|
293
|
+
if time.monotonic() > deadline:
|
|
294
|
+
raise ProofUnreadable("libproc: deadline")
|
|
295
|
+
size = lib.proc_pidinfo(pid, PROC_PIDLISTFDS, 0, None, 0)
|
|
296
|
+
if size <= 0:
|
|
297
|
+
continue
|
|
298
|
+
fds = ctypes.create_string_buffer(min(size + 32 * 8, MAX_FDS * 8))
|
|
299
|
+
size = lib.proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, len(fds))
|
|
300
|
+
for i in range(max(size, 0) // 8):
|
|
301
|
+
fd, kind = struct.unpack_from("<iI", fds, i * 8)
|
|
302
|
+
if kind != PROX_FDTYPE_SOCKET:
|
|
303
|
+
continue
|
|
304
|
+
if lib.proc_pidfdinfo(pid, fd, PROC_PIDFDSOCKETINFO, record, SOCKET_FDINFO_SIZE) != SOCKET_FDINFO_SIZE:
|
|
305
|
+
continue
|
|
306
|
+
parsed = parse_socket_fdinfo(record.raw)
|
|
307
|
+
if parsed is not None:
|
|
308
|
+
out.append((pid,) + parsed)
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _calibrate_libproc() -> Optional[bool]:
|
|
313
|
+
"""Whether the `socket_fdinfo` layout reads back a connected pair of this process's own (the listener and both
|
|
314
|
+
ends, each once, with their exact addresses and this pid); None when libproc could not be read at all, which the
|
|
315
|
+
caller does not keep (FAA-273 item 11: an unread calibration is not a verdict)."""
|
|
316
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
317
|
+
client = taken = None
|
|
318
|
+
try:
|
|
319
|
+
probe.bind(("127.0.0.1", 0))
|
|
320
|
+
probe.listen(1)
|
|
321
|
+
client = socket.create_connection(probe.getsockname(), timeout=TIMEOUT_SECONDS)
|
|
322
|
+
taken, _ = probe.accept()
|
|
323
|
+
server, near = probe.getsockname()[:2], client.getsockname()[:2]
|
|
324
|
+
have = own_sockets(_libproc_records(os.getuid(), time.monotonic() + TIMEOUT_SECONDS))
|
|
325
|
+
me = os.getpid()
|
|
326
|
+
return all(len(hits) == 1 and hits[0].pid == me for hits in (
|
|
327
|
+
[s for s in have if s.listening and s.local == server],
|
|
328
|
+
[s for s in have if not s.listening and s.local == server and s.remote == near],
|
|
329
|
+
[s for s in have if not s.listening and s.local == near and s.remote == server]))
|
|
330
|
+
except (ProofUnreadable, OSError, struct.error, ValueError):
|
|
331
|
+
return None
|
|
332
|
+
finally:
|
|
333
|
+
for s in (client, taken, probe):
|
|
334
|
+
if s is not None:
|
|
335
|
+
s.close()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def method(platform: Optional[str] = None) -> str:
|
|
339
|
+
"""The reader the next question uses: libproc once its layout passed calibration, else lsof; none off macOS."""
|
|
340
|
+
if (platform or sys.platform) != "darwin":
|
|
341
|
+
return "none"
|
|
342
|
+
with _LOCK:
|
|
343
|
+
if _LIBPROC_OK[0] is None:
|
|
344
|
+
_LIBPROC_OK[0] = _calibrate_libproc()
|
|
345
|
+
return LIBPROC if _LIBPROC_OK[0] else LSOF_METHOD
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _ask(port: int, state: str, pick: Callable[[List[Socket], str], Proof], wait: float, platform: Optional[str],
|
|
349
|
+
uid: Optional[int], euid: Optional[int], how: Optional[str], run: Optional[Callable[[List[str]], str]]) -> Proof:
|
|
350
|
+
start = time.monotonic()
|
|
351
|
+
if (platform or sys.platform) != "darwin":
|
|
352
|
+
return Proof(False, None, "none", "%s: unsupported-platform" % TABLE_RESTRICTED)
|
|
353
|
+
uid = os.getuid() if uid is None else uid
|
|
354
|
+
euid = os.geteuid() if euid is None else euid
|
|
355
|
+
if uid != euid:
|
|
356
|
+
return Proof(False, None, how or "none", "%s: setuid-context" % TABLE_RESTRICTED)
|
|
357
|
+
how = how or method(platform)
|
|
358
|
+
pause = 0.005
|
|
359
|
+
while True:
|
|
360
|
+
try:
|
|
361
|
+
if how == LIBPROC:
|
|
362
|
+
have = own_sockets(_libproc_records(uid, start + TIMEOUT_SECONDS))
|
|
363
|
+
else:
|
|
364
|
+
argv = [LSOF, "-nP", "-a", "-u", str(uid), "-iTCP:%d" % port, "-sTCP:%s" % state, "-F", "pun"]
|
|
365
|
+
have = parse_lsof_sockets((run or _run)(argv), uid)
|
|
366
|
+
except ProofUnreadable as exc:
|
|
367
|
+
return Proof(False, None, how, "%s: %s" % (TABLE_RESTRICTED, exc))
|
|
368
|
+
answer = pick(have, how)
|
|
369
|
+
if answer.proved or time.monotonic() + pause > start + min(wait, TIMEOUT_SECONDS):
|
|
370
|
+
return answer
|
|
371
|
+
time.sleep(pause)
|
|
372
|
+
pause = min(pause * 2, 0.05)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def accepted(server: Address, client: Address, *, wait: bool = False, platform: Optional[str] = None,
|
|
376
|
+
uid: Optional[int] = None, euid: Optional[int] = None, how: Optional[str] = None,
|
|
377
|
+
run: Optional[Callable[[List[str]], str]] = None) -> Proof:
|
|
378
|
+
"""RT-2: whether the socket at `server` connected to `client` (a client's own connection, seen from the server's
|
|
379
|
+
side) is in one of this account's processes. `wait`: a server end is read again for ACCEPT_WAIT_SECONDS, since a
|
|
380
|
+
client asks right after connect() and the server's accepted socket exists only once it calls accept().
|
|
381
|
+
`platform`, `uid`, `euid`, `how` and `run` are the tests'."""
|
|
382
|
+
server, client = (_host(str(server[0])), int(server[1])), (_host(str(client[0])), int(client[1]))
|
|
383
|
+
return _ask(server[1], "ESTABLISHED", lambda have, how_: accepted_in(have, server, client, how_),
|
|
384
|
+
ACCEPT_WAIT_SECONDS if wait else 0.0, platform, uid, euid, how, run)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def own_listener(port: int, *, platform: Optional[str] = None, uid: Optional[int] = None, euid: Optional[int] = None,
|
|
388
|
+
how: Optional[str] = None, run: Optional[Callable[[List[str]], str]] = None) -> Proof:
|
|
389
|
+
"""FAA-272: whether one of this account's processes listens on exactly 127.0.0.1:<port>."""
|
|
390
|
+
return _ask(int(port), "LISTEN", lambda have, how_: listener_in(have, int(port), how_), 0.0, platform, uid, euid,
|
|
391
|
+
how, run)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
1
2
|
"""Supervisors (DEC-0032, LC-14): launchd plists, systemd user units and Task Scheduler tasks, installed and
|
|
2
3
|
removed without touching what the operator turned off; release pruning (LC-15). Part of the service kit
|
|
3
4
|
(FAA-65, layer L3)."""
|
|
@@ -25,6 +26,7 @@ from pathlib import Path as _Path # noqa: E402
|
|
|
25
26
|
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
26
27
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
27
28
|
import fabric_peer_owner as _po # noqa: E402 - the listener's owner (DEC-0154 HA-2)
|
|
29
|
+
import fabric_socket_proof as _sp # noqa: E402 - the one loopback host rule (FAA-326)
|
|
28
30
|
from fabric_base import (
|
|
29
31
|
DRAIN_SECONDS,
|
|
30
32
|
SUPERVISOR_ENV,
|
|
@@ -462,16 +464,20 @@ def launchd_pid(label: str, domain: Optional[str] = None) -> Optional[int]:
|
|
|
462
464
|
return None
|
|
463
465
|
|
|
464
466
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
467
|
+
#: Whether urlsplit's hostname names this machine: the kit's one loopback rule (FAA-326), so 127.1, 2130706433 and a
|
|
468
|
+
#: name under .localhost, which the Node twin's `new URL` reads as loopback, take the checked path here too.
|
|
469
|
+
_loopback_host = _sp.loopback_host
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _loopback_otherwise(host: Optional[str]) -> bool:
|
|
473
|
+
"""A loopback address the kit's rule does not take: one with a zone (`::1%lo0`) or IPv4-mapped (`::ffff:127.0.0.1`).
|
|
474
|
+
urlopen reaches loopback through either, so it is refused unread, as Node refuses it (0.84.0 seam reading)."""
|
|
471
475
|
try:
|
|
472
|
-
|
|
476
|
+
address = ipaddress.ip_address((host or "").split("%", 1)[0])
|
|
473
477
|
except ValueError:
|
|
474
478
|
return False
|
|
479
|
+
mapped = getattr(address, "ipv4_mapped", None)
|
|
480
|
+
return bool(address.is_loopback or (mapped and mapped.is_loopback))
|
|
475
481
|
|
|
476
482
|
|
|
477
483
|
class ForeignUserAnswer:
|
|
@@ -498,7 +504,7 @@ def _read_well_known(origin: str, timeout: float = 2.0,
|
|
|
498
504
|
except ValueError: # a port out of range
|
|
499
505
|
port = None
|
|
500
506
|
if parts.scheme == "http" and port and _loopback_host(parts.hostname):
|
|
501
|
-
# every loopback spelling (127
|
|
507
|
+
# every loopback spelling (127/8 in any form, localhost or a name under it, [::1]) goes on a checked connection,
|
|
502
508
|
# never the unchecked way (FAA-257)
|
|
503
509
|
conn = _po.CheckedHTTPConnection(parts.hostname, port, timeout=timeout, vouch_pid=vouch_pid)
|
|
504
510
|
try:
|
|
@@ -512,6 +518,8 @@ def _read_well_known(origin: str, timeout: float = 2.0,
|
|
|
512
518
|
return None, None
|
|
513
519
|
finally:
|
|
514
520
|
conn.close()
|
|
521
|
+
if _loopback_otherwise(parts.hostname):
|
|
522
|
+
return None, None
|
|
515
523
|
try:
|
|
516
524
|
with urllib.request.urlopen(origin + "/.well-known/fabric-service", timeout=timeout) as response:
|
|
517
525
|
return json.loads(response.read().decode()), None
|
|
@@ -522,14 +530,9 @@ def _read_well_known(origin: str, timeout: float = 2.0,
|
|
|
522
530
|
def _job_listens(pid: int, port: int) -> bool:
|
|
523
531
|
"""Whether launchd's job `pid` listens on 127.0.0.1:<port> exactly (never the wildcard), asked of the process itself
|
|
524
532
|
(`lsof -p`), which a restricted socket table does not hide. Used only for a LaunchDaemon root just bootstrapped,
|
|
525
|
-
whose pid launchd itself named (FAA-278).
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
try:
|
|
529
|
-
out = _po._run([_po.LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
|
|
530
|
-
except _po.TableUnreadable:
|
|
531
|
-
return False
|
|
532
|
-
return any(host == "127.0.0.1" and p == port for host, p in _po.parse_lsof_listeners(out)) # exact bind, as RT-3
|
|
533
|
+
whose pid launchd itself named (FAA-278). The peer owner's one lsof reader, the uid left to the caller, which checks
|
|
534
|
+
the job's UserName itself (FAA-286 item 1)."""
|
|
535
|
+
return _po.pid_listens(pid, port, owner=None)
|
|
533
536
|
|
|
534
537
|
|
|
535
538
|
def _account_uid(user_name: str) -> Optional[int]:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
2
3
|
"""Who is calling an agent that sits behind `tailscale serve`, and what that person may open (FAA-297). Standard
|
|
3
4
|
library only; the Node twin is fabric-tailnet.mjs, and both answer test/fixtures/tailnet/vectors.json alike.
|
|
4
5
|
|