maf-sandbox-shell 0.1.0__tar.gz
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.
- maf_sandbox_shell-0.1.0/LICENSE +21 -0
- maf_sandbox_shell-0.1.0/PKG-INFO +48 -0
- maf_sandbox_shell-0.1.0/README.md +23 -0
- maf_sandbox_shell-0.1.0/pyproject.toml +86 -0
- maf_sandbox_shell-0.1.0/pyproject.toml.orig +57 -0
- maf_sandbox_shell-0.1.0/src/maf_sandbox_shell/__init__.py +44 -0
- maf_sandbox_shell-0.1.0/src/maf_sandbox_shell/_paths.py +79 -0
- maf_sandbox_shell-0.1.0/src/maf_sandbox_shell/_report.py +232 -0
- maf_sandbox_shell-0.1.0/src/maf_sandbox_shell/_spec.py +93 -0
- maf_sandbox_shell-0.1.0/src/maf_sandbox_shell/_tool.py +206 -0
- maf_sandbox_shell-0.1.0/src/maf_sandbox_shell/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SOKOLAI BV
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maf-sandbox-shell
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline Bash, POSIX shell and PowerShell validation in disposable sandboxes.
|
|
5
|
+
Keywords: shell,bash,powershell,sandbox,agent-framework
|
|
6
|
+
Author: SOKOLAI BV
|
|
7
|
+
Author-email: SOKOLAI BV <info@sokolai.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Requires-Dist: maf-sandbox>=0.51.0,<0.52
|
|
19
|
+
Requires-Dist: agent-framework-core>=1.22.0,<1.23
|
|
20
|
+
Requires-Python: >=3.12, <3.15
|
|
21
|
+
Project-URL: Homepage, https://www.sokol.ai
|
|
22
|
+
Project-URL: Source, https://github.com/sokolaidev/maf-extensions
|
|
23
|
+
Project-URL: Issues, https://github.com/sokolaidev/maf-extensions/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# maf-sandbox-shell
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/maf-sandbox-shell/) [](https://pypi.org/project/maf-sandbox-shell/) [](https://github.com/sokolaidev/maf-extensions/blob/main/packages/maf-sandbox-shell/LICENSE)
|
|
29
|
+
|
|
30
|
+
> **Experimental.** Releases before 1.0 may change or remove APIs. Importing this package emits `MafSandboxShellExperimentalWarning`.
|
|
31
|
+
|
|
32
|
+
Validate Bash, POSIX sh, dash and PowerShell scripts with fixed offline analyzers in disposable sandboxes. The host selects the flavor, image and policy; the model supplies an explicit file manifest. Inline lint suppressions are honored and reported.
|
|
33
|
+
|
|
34
|
+
This package is experimental and has not been released. See the [shell kind guide](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/kinds/shell.md) for the accepted design and qualification status.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from maf_sandbox_shell import ShellFlavor, make_shell_tools
|
|
38
|
+
|
|
39
|
+
tools = make_shell_tools(
|
|
40
|
+
router, file_store, agent_id, context,
|
|
41
|
+
flavor=ShellFlavor.BASH,
|
|
42
|
+
image="your-qualified-shell-image",
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The Bash flavor accepts `.sh`, `.bash`, `.dash` and extensionless files with a supported shebang. PowerShell accepts `.ps1` and `.psm1`; dependencies must be installed in the host image's approved inventory. Both tools require closed egress, POSIX, `EXEC`, `FILES_IN`, call isolation and disposal. Validation never writes the host file store or deploys infrastructure.
|
|
47
|
+
|
|
48
|
+
Completed checks return `clean` or `findings`. Every unsuppressed severity counts by default; the host can set a threshold and excluded rules through `ShellPolicy`. Unfinished analysis returns no verdict. Exact diagnostics and suppression details remain untrusted workload output; trusted summaries use package-owned text and `files[N]` references.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# maf-sandbox-shell
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/maf-sandbox-shell/) [](https://pypi.org/project/maf-sandbox-shell/) [](https://github.com/sokolaidev/maf-extensions/blob/main/packages/maf-sandbox-shell/LICENSE)
|
|
4
|
+
|
|
5
|
+
> **Experimental.** Releases before 1.0 may change or remove APIs. Importing this package emits `MafSandboxShellExperimentalWarning`.
|
|
6
|
+
|
|
7
|
+
Validate Bash, POSIX sh, dash and PowerShell scripts with fixed offline analyzers in disposable sandboxes. The host selects the flavor, image and policy; the model supplies an explicit file manifest. Inline lint suppressions are honored and reported.
|
|
8
|
+
|
|
9
|
+
This package is experimental and has not been released. See the [shell kind guide](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/kinds/shell.md) for the accepted design and qualification status.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from maf_sandbox_shell import ShellFlavor, make_shell_tools
|
|
13
|
+
|
|
14
|
+
tools = make_shell_tools(
|
|
15
|
+
router, file_store, agent_id, context,
|
|
16
|
+
flavor=ShellFlavor.BASH,
|
|
17
|
+
image="your-qualified-shell-image",
|
|
18
|
+
)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The Bash flavor accepts `.sh`, `.bash`, `.dash` and extensionless files with a supported shebang. PowerShell accepts `.ps1` and `.psm1`; dependencies must be installed in the host image's approved inventory. Both tools require closed egress, POSIX, `EXEC`, `FILES_IN`, call isolation and disposal. Validation never writes the host file store or deploys infrastructure.
|
|
22
|
+
|
|
23
|
+
Completed checks return `clean` or `findings`. Every unsuppressed severity counts by default; the host can set a threshold and excluded rules through `ShellPolicy`. Unfinished analysis returns no verdict. Exact diagnostics and suppression details remain untrusted workload output; trusted summaries use package-owned text and `files[N]` references.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "maf-sandbox-shell"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Offline Bash, POSIX shell and PowerShell validation in disposable sandboxes."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<3.15"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"shell",
|
|
11
|
+
"bash",
|
|
12
|
+
"powershell",
|
|
13
|
+
"sandbox",
|
|
14
|
+
"agent-framework",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"maf-sandbox>=0.51.0,<0.52",
|
|
28
|
+
"agent-framework-core>=1.22.0,<1.23",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[[project.authors]]
|
|
32
|
+
name = "SOKOLAI BV"
|
|
33
|
+
email = "info@sokolai.com"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://www.sokol.ai"
|
|
37
|
+
Source = "https://github.com/sokolaidev/maf-extensions"
|
|
38
|
+
Issues = "https://github.com/sokolaidev/maf-extensions/issues"
|
|
39
|
+
|
|
40
|
+
[tool.uv.sources.maf-sandbox]
|
|
41
|
+
workspace = true
|
|
42
|
+
|
|
43
|
+
[tool.uv.build-backend]
|
|
44
|
+
module-name = "maf_sandbox_shell"
|
|
45
|
+
module-root = "src"
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py312"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
extend-select = [
|
|
53
|
+
"I",
|
|
54
|
+
"UP",
|
|
55
|
+
"D100",
|
|
56
|
+
"D101",
|
|
57
|
+
"D103",
|
|
58
|
+
"D104",
|
|
59
|
+
"E501",
|
|
60
|
+
"W505",
|
|
61
|
+
"TD",
|
|
62
|
+
"FIX",
|
|
63
|
+
"ERA001",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.per-file-ignores]
|
|
67
|
+
"tests/**" = [
|
|
68
|
+
"D101",
|
|
69
|
+
"D103",
|
|
70
|
+
"E501",
|
|
71
|
+
"W505",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint.pycodestyle]
|
|
75
|
+
max-doc-length = 100
|
|
76
|
+
|
|
77
|
+
[tool.pyright]
|
|
78
|
+
include = ["src"]
|
|
79
|
+
typeCheckingMode = "strict"
|
|
80
|
+
|
|
81
|
+
[tool.pytest.ini_options]
|
|
82
|
+
testpaths = ["tests"]
|
|
83
|
+
|
|
84
|
+
[build-system]
|
|
85
|
+
requires = ["uv_build>=0.11.24,<0.12.0"]
|
|
86
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "maf-sandbox-shell"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Offline Bash, POSIX shell and PowerShell validation in disposable sandboxes."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<3.15"
|
|
7
|
+
authors = [{ name = "SOKOLAI BV", email = "info@sokolai.com" }]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
keywords = ["shell", "bash", "powershell", "sandbox", "agent-framework"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Typing :: Typed",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["maf-sandbox>=0.51.0,<0.52", "agent-framework-core>=1.22.0,<1.23"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://www.sokol.ai"
|
|
25
|
+
Source = "https://github.com/sokolaidev/maf-extensions"
|
|
26
|
+
Issues = "https://github.com/sokolaidev/maf-extensions/issues"
|
|
27
|
+
|
|
28
|
+
[tool.uv.sources]
|
|
29
|
+
maf-sandbox = { workspace = true }
|
|
30
|
+
|
|
31
|
+
[tool.uv.build-backend]
|
|
32
|
+
module-name = "maf_sandbox_shell"
|
|
33
|
+
module-root = "src"
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 100
|
|
37
|
+
target-version = "py312"
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
extend-select = ["I", "UP", "D100", "D101", "D103", "D104", "E501", "W505", "TD", "FIX", "ERA001"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint.per-file-ignores]
|
|
43
|
+
"tests/**" = ["D101", "D103", "E501", "W505"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint.pycodestyle]
|
|
46
|
+
max-doc-length = 100
|
|
47
|
+
|
|
48
|
+
[tool.pyright]
|
|
49
|
+
include = ["src"]
|
|
50
|
+
typeCheckingMode = "strict"
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
|
|
55
|
+
[build-system]
|
|
56
|
+
requires = ["uv_build>=0.11.24,<0.12.0"]
|
|
57
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Offline shell script validation for Microsoft Agent Framework."""
|
|
2
|
+
|
|
3
|
+
import warnings as _warnings
|
|
4
|
+
|
|
5
|
+
from ._spec import (
|
|
6
|
+
SHELL_TOOL_NAMES,
|
|
7
|
+
ShellDialect,
|
|
8
|
+
ShellFlavor,
|
|
9
|
+
ShellPolicy,
|
|
10
|
+
ShellSeverity,
|
|
11
|
+
shell_sandbox_spec,
|
|
12
|
+
)
|
|
13
|
+
from ._tool import make_shell_tools
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MafSandboxShellExperimentalWarning",
|
|
17
|
+
"SHELL_TOOL_NAMES",
|
|
18
|
+
"ShellDialect",
|
|
19
|
+
"ShellFlavor",
|
|
20
|
+
"ShellPolicy",
|
|
21
|
+
"ShellSeverity",
|
|
22
|
+
"make_shell_tools",
|
|
23
|
+
"shell_sandbox_spec",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MafSandboxShellExperimentalWarning(UserWarning):
|
|
28
|
+
"""Warning category for the experimental shell workload package."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _warn_experimental() -> None:
|
|
32
|
+
try:
|
|
33
|
+
_warnings.warn(
|
|
34
|
+
"maf_sandbox_shell is experimental and may change or be removed in future "
|
|
35
|
+
"versions without notice.",
|
|
36
|
+
category=MafSandboxShellExperimentalWarning,
|
|
37
|
+
stacklevel=2,
|
|
38
|
+
)
|
|
39
|
+
except MafSandboxShellExperimentalWarning:
|
|
40
|
+
# An informational warning must not block imports under -Werror.
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_warn_experimental()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Resolve explicit file manifests without exposing names in refusal messages."""
|
|
2
|
+
|
|
3
|
+
import posixpath
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from maf_sandbox import ListedFile
|
|
7
|
+
|
|
8
|
+
from ._spec import ShellDialect, ShellFlavor
|
|
9
|
+
|
|
10
|
+
_RESERVED = {".shellcheckrc", "shellcheckrc", "psscriptanalyzersettings.psd1"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalize(name: object) -> str:
|
|
14
|
+
"""Accept bounded store-relative paths, including leading-hyphen basenames."""
|
|
15
|
+
if (
|
|
16
|
+
not isinstance(name, str)
|
|
17
|
+
or not name
|
|
18
|
+
or len(name) > 512
|
|
19
|
+
or not re.fullmatch(r"[A-Za-z0-9._/-]+", name)
|
|
20
|
+
or name.startswith("/")
|
|
21
|
+
or name.endswith("/")
|
|
22
|
+
or ".." in name.split("/")
|
|
23
|
+
or any(part.lower() in _RESERVED for part in name.split("/"))
|
|
24
|
+
):
|
|
25
|
+
raise ValueError("The manifest contains an unsupported or reserved path.")
|
|
26
|
+
path = posixpath.normpath(name)
|
|
27
|
+
if path == ".":
|
|
28
|
+
raise ValueError("Every manifest entry must name a file.")
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_manifest(
|
|
33
|
+
files: list[str], listing: list[ListedFile], flavor: ShellFlavor
|
|
34
|
+
) -> list[tuple[str, ListedFile]]:
|
|
35
|
+
"""Keep listing objects for provenance and refuse ambiguous normalized names."""
|
|
36
|
+
paths = [normalize(name) for name in files]
|
|
37
|
+
if len(set(paths)) != len(paths):
|
|
38
|
+
raise ValueError("The manifest contains duplicate destinations.")
|
|
39
|
+
indexed: dict[str, ListedFile] = {}
|
|
40
|
+
for item in listing:
|
|
41
|
+
try:
|
|
42
|
+
path = normalize(item.name)
|
|
43
|
+
except ValueError:
|
|
44
|
+
continue
|
|
45
|
+
if path in indexed:
|
|
46
|
+
raise ValueError("The file listing contains ambiguous normalized paths.")
|
|
47
|
+
indexed[path] = item
|
|
48
|
+
for path in paths:
|
|
49
|
+
suffix = posixpath.splitext(path)[1].lower()
|
|
50
|
+
allowed = {".ps1", ".psm1"} if flavor == ShellFlavor.PWSH else {".sh", ".bash", ".dash", ""}
|
|
51
|
+
if suffix not in allowed:
|
|
52
|
+
raise ValueError("The manifest contains a file unsupported by this shell flavor.")
|
|
53
|
+
if path not in indexed:
|
|
54
|
+
raise ValueError("A manifest file is absent from this tool's file listing.")
|
|
55
|
+
return [(path, indexed[path]) for path in paths]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def dialect_for(path: str, text: str, default: ShellDialect) -> ShellDialect:
|
|
59
|
+
"""Select a fixed grammar without executing or forwarding the shebang."""
|
|
60
|
+
suffix = posixpath.splitext(path)[1].lower()
|
|
61
|
+
named = {".bash": ShellDialect.BASH, ".dash": ShellDialect.DASH}.get(suffix)
|
|
62
|
+
if text.startswith("#!"):
|
|
63
|
+
words = text.splitlines()[0][2:].strip().split()
|
|
64
|
+
if len(words) == 2 and words[0] == "/usr/bin/env":
|
|
65
|
+
executable = words[1]
|
|
66
|
+
elif len(words) == 1 and words[0].startswith("/"):
|
|
67
|
+
executable = posixpath.basename(words[0])
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError("The shebang is not a supported fixed shell declaration.")
|
|
70
|
+
try:
|
|
71
|
+
dialect = ShellDialect(executable)
|
|
72
|
+
except ValueError:
|
|
73
|
+
raise ValueError("The shebang names an unsupported shell dialect.") from None
|
|
74
|
+
if named is not None and named != dialect:
|
|
75
|
+
raise ValueError("The file extension and shebang declare conflicting dialects.")
|
|
76
|
+
return dialect
|
|
77
|
+
if not suffix:
|
|
78
|
+
raise ValueError("An extensionless shell file requires a supported shebang.")
|
|
79
|
+
return named or default
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Validate complete analyzer envelopes and separate finite summaries from guest prose."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from ._spec import SEVERITIES, ShellFlavor, ShellPolicy, ShellSeverity
|
|
12
|
+
|
|
13
|
+
MAX_REPORT_BYTES = 1024 * 1024
|
|
14
|
+
EMPTY_INVENTORY_SHA256 = hashlib.sha256(b"{}\n").hexdigest()
|
|
15
|
+
IDENTITY = {
|
|
16
|
+
"launcher": "maf-shell-v1",
|
|
17
|
+
"bash": "5.2.15-2+b13",
|
|
18
|
+
"dash": "0.5.12-2",
|
|
19
|
+
"shellcheck": "0.11.0",
|
|
20
|
+
"powershell": "7.6.7",
|
|
21
|
+
"psscriptanalyzer": "1.25.0",
|
|
22
|
+
}
|
|
23
|
+
_CATALOG = {
|
|
24
|
+
"SC1090": "A dynamically selected source could not be followed.",
|
|
25
|
+
"SC1091": "A referenced source could not be read.",
|
|
26
|
+
"SC2034": "A variable appears unused.",
|
|
27
|
+
"SC2046": "Quote command substitution to prevent word splitting.",
|
|
28
|
+
"SC2086": "Quote variable expansion to prevent splitting and globbing.",
|
|
29
|
+
"SC2154": "A variable is referenced without an assignment.",
|
|
30
|
+
"SC2162": "Use read -r to preserve backslashes.",
|
|
31
|
+
"SC2164": "Handle failure when changing directories.",
|
|
32
|
+
"PSAvoidUsingCmdletAliases": "Use a command's full name instead of an alias.",
|
|
33
|
+
"PSAvoidUsingWriteHost": "Choose an output stream appropriate to the caller.",
|
|
34
|
+
"PSAvoidUsingInvokeExpression": "Avoid evaluating strings as PowerShell code.",
|
|
35
|
+
"PSAvoidUsingPlainTextForPassword": "Use a credential type for password parameters.",
|
|
36
|
+
"PSUseDeclaredVarsMoreThanAssignments": "A variable appears unused.",
|
|
37
|
+
"PSUseApprovedVerbs": "Use an approved verb in a function name.",
|
|
38
|
+
"PSParseError": "PowerShell could not parse the source.",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Outcome:
|
|
44
|
+
"""Only a completed analysis can carry a verdict; details always remain untrusted."""
|
|
45
|
+
|
|
46
|
+
completed: bool
|
|
47
|
+
verdict: str | None
|
|
48
|
+
summary: tuple[str, ...]
|
|
49
|
+
detail: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
53
|
+
result: dict[str, Any] = {}
|
|
54
|
+
for key, value in pairs:
|
|
55
|
+
if key in result:
|
|
56
|
+
raise ValueError("duplicate JSON key")
|
|
57
|
+
result[key] = value
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _nonfinite(value: str) -> Any:
|
|
62
|
+
raise ValueError("nonfinite JSON value")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _mapping(value: Any) -> dict[str, Any]:
|
|
66
|
+
if not isinstance(value, dict):
|
|
67
|
+
raise ValueError("expected object")
|
|
68
|
+
return cast(dict[str, Any], value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _list(value: Any) -> list[Any]:
|
|
72
|
+
if not isinstance(value, list):
|
|
73
|
+
raise ValueError("expected array")
|
|
74
|
+
return cast(list[Any], value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _position(value: dict[str, Any]) -> None:
|
|
78
|
+
if any(
|
|
79
|
+
type(value.get(key)) is not int or not 0 <= value[key] <= 2**31
|
|
80
|
+
for key in ("line", "column")
|
|
81
|
+
):
|
|
82
|
+
raise ValueError("invalid source location")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _diagnostic(value: Any, paths: list[str]) -> dict[str, Any]:
|
|
86
|
+
item = _mapping(value)
|
|
87
|
+
_position(item)
|
|
88
|
+
if (
|
|
89
|
+
item.get("file") not in paths
|
|
90
|
+
or item.get("severity") not in SEVERITIES
|
|
91
|
+
or not isinstance(item.get("rule"), str)
|
|
92
|
+
or not item["rule"]
|
|
93
|
+
or not isinstance(item.get("message"), str)
|
|
94
|
+
):
|
|
95
|
+
raise ValueError("invalid diagnostic")
|
|
96
|
+
return item
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _suppression(value: Any, flavor: ShellFlavor) -> None:
|
|
100
|
+
item = _mapping(value)
|
|
101
|
+
_position(item)
|
|
102
|
+
if flavor == ShellFlavor.PWSH:
|
|
103
|
+
if not isinstance(item.get("text"), str) or not item["text"]:
|
|
104
|
+
raise ValueError("invalid suppression declaration")
|
|
105
|
+
return
|
|
106
|
+
ranges = _list(item.get("rules"))
|
|
107
|
+
if not ranges:
|
|
108
|
+
raise ValueError("empty suppression declaration")
|
|
109
|
+
for value in ranges:
|
|
110
|
+
span = _mapping(value)
|
|
111
|
+
if any(
|
|
112
|
+
type(span.get(key)) is not int or span[key] < 0 for key in ("first", "end_exclusive")
|
|
113
|
+
):
|
|
114
|
+
raise ValueError("invalid suppression range")
|
|
115
|
+
if item.get("scope") is not None:
|
|
116
|
+
scope = _mapping(item["scope"])
|
|
117
|
+
_position(_mapping(scope.get("start")))
|
|
118
|
+
_position(_mapping(scope.get("end")))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def report_outcome(
|
|
122
|
+
raw: bytes,
|
|
123
|
+
flavor: ShellFlavor,
|
|
124
|
+
paths: list[str],
|
|
125
|
+
policy: ShellPolicy,
|
|
126
|
+
inventory_sha256: str = EMPTY_INVENTORY_SHA256,
|
|
127
|
+
*,
|
|
128
|
+
hidden: bool = False,
|
|
129
|
+
) -> Outcome:
|
|
130
|
+
"""Recompute verdicts from diagnostics, honoring inline suppressions and host policy."""
|
|
131
|
+
if len(raw) > MAX_REPORT_BYTES:
|
|
132
|
+
raise ValueError("report exceeds bound")
|
|
133
|
+
envelope = _mapping(
|
|
134
|
+
json.loads(raw.decode("utf-8"), object_pairs_hook=_object, parse_constant=_nonfinite)
|
|
135
|
+
)
|
|
136
|
+
if (
|
|
137
|
+
type(envelope.get("protocol")) is not int
|
|
138
|
+
or envelope["protocol"] != 1
|
|
139
|
+
or envelope.get("identity") != IDENTITY
|
|
140
|
+
or envelope.get("flavor") != flavor
|
|
141
|
+
or "error" not in envelope
|
|
142
|
+
):
|
|
143
|
+
raise ValueError("unsupported launcher identity")
|
|
144
|
+
if envelope["error"] is not None:
|
|
145
|
+
return Outcome(
|
|
146
|
+
False, None, ("Validation incomplete: the bounded analyzer did not finish.",), ""
|
|
147
|
+
)
|
|
148
|
+
if envelope.get("dependency_inventory_sha256") != inventory_sha256:
|
|
149
|
+
raise ValueError("unexpected host dependency inventory")
|
|
150
|
+
reports = [_mapping(item) for item in _list(envelope.get("files"))]
|
|
151
|
+
if [item.get("path") for item in reports] != paths:
|
|
152
|
+
raise ValueError("report does not cover the complete manifest")
|
|
153
|
+
completed = True
|
|
154
|
+
findings = False
|
|
155
|
+
summaries: set[str] = set()
|
|
156
|
+
details: list[dict[str, Any]] = []
|
|
157
|
+
for report in reports:
|
|
158
|
+
if (
|
|
159
|
+
type(report.get("parsed")) is not bool
|
|
160
|
+
or type(report.get("policy_conflict")) is not bool
|
|
161
|
+
):
|
|
162
|
+
raise ValueError("missing analyzer completion")
|
|
163
|
+
syntax = _mapping(report.get("syntax"))
|
|
164
|
+
if (
|
|
165
|
+
type(syntax.get("exit_code")) is not int
|
|
166
|
+
or not isinstance(syntax.get("stdout"), str)
|
|
167
|
+
or not isinstance(syntax.get("stderr"), str)
|
|
168
|
+
):
|
|
169
|
+
raise ValueError("invalid syntax phase")
|
|
170
|
+
completed &= report["parsed"] and not report["policy_conflict"] and syntax["exit_code"] == 0
|
|
171
|
+
diagnostics = [_diagnostic(item, paths) for item in _list(report.get("diagnostics"))]
|
|
172
|
+
suppressed = [
|
|
173
|
+
_diagnostic(item, paths) for item in _list(report.get("suppressed_diagnostics"))
|
|
174
|
+
]
|
|
175
|
+
declarations = _list(report.get("suppressions"))
|
|
176
|
+
for declaration in declarations:
|
|
177
|
+
_suppression(declaration, flavor)
|
|
178
|
+
if declarations or suppressed:
|
|
179
|
+
summaries.add("Inline suppressions are present; see the untrusted audit report.")
|
|
180
|
+
excluded = [item for item in diagnostics if item["rule"] in policy.excluded_rules]
|
|
181
|
+
effective = [item for item in diagnostics if item["rule"] not in policy.excluded_rules]
|
|
182
|
+
findings |= any(
|
|
183
|
+
SEVERITIES.index(ShellSeverity(item["severity"]))
|
|
184
|
+
<= SEVERITIES.index(policy.minimum_severity)
|
|
185
|
+
for item in effective
|
|
186
|
+
)
|
|
187
|
+
for item in effective:
|
|
188
|
+
if item["rule"] in _CATALOG:
|
|
189
|
+
index = paths.index(item["file"])
|
|
190
|
+
summaries.add(
|
|
191
|
+
f"files[{index}]: {item['severity']} {item['rule']}. {_CATALOG[item['rule']]}"
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
summaries.add("Additional analyzer findings are available in the untrusted report.")
|
|
195
|
+
details.append(
|
|
196
|
+
{
|
|
197
|
+
**report,
|
|
198
|
+
"counts": {
|
|
199
|
+
severity: sum(item["severity"] == severity for item in effective)
|
|
200
|
+
for severity in SEVERITIES
|
|
201
|
+
},
|
|
202
|
+
"suppressed_counts": dict(Counter(item["severity"] for item in suppressed)),
|
|
203
|
+
"suppression_declarations": len(declarations),
|
|
204
|
+
"suppressed_findings": len(suppressed),
|
|
205
|
+
"host_excluded_findings": len(excluded),
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
if not completed:
|
|
209
|
+
summaries.add(
|
|
210
|
+
"Validation incomplete: syntax, lint or suppression auditing could not complete."
|
|
211
|
+
)
|
|
212
|
+
elif not findings:
|
|
213
|
+
summaries.add("No unsuppressed diagnostic meets the configured threshold.")
|
|
214
|
+
if policy.excluded_rules:
|
|
215
|
+
summaries.add("Host rule exclusions are active.")
|
|
216
|
+
return Outcome(
|
|
217
|
+
completed,
|
|
218
|
+
("findings" if findings else "clean") if completed else None,
|
|
219
|
+
tuple(sorted(summaries)),
|
|
220
|
+
""
|
|
221
|
+
if hidden
|
|
222
|
+
else json.dumps(
|
|
223
|
+
{
|
|
224
|
+
"files": details,
|
|
225
|
+
"policy": {
|
|
226
|
+
"minimum_severity": policy.minimum_severity,
|
|
227
|
+
"excluded_rules": policy.excluded_rules,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
ensure_ascii=True,
|
|
231
|
+
),
|
|
232
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Host-selected analyzers and the boundary required for static shell validation."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from maf_sandbox import (
|
|
8
|
+
Capability,
|
|
9
|
+
Cleanup,
|
|
10
|
+
Egress,
|
|
11
|
+
Isolation,
|
|
12
|
+
IsolationScope,
|
|
13
|
+
OsFamily,
|
|
14
|
+
SandboxSpec,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ShellFlavor(StrEnum):
|
|
19
|
+
"""The analyzer family enabled by the host."""
|
|
20
|
+
|
|
21
|
+
BASH = "bash"
|
|
22
|
+
PWSH = "pwsh"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ShellDialect(StrEnum):
|
|
26
|
+
"""The fixed POSIX-family grammars supported by the Bash flavor."""
|
|
27
|
+
|
|
28
|
+
BASH = "bash"
|
|
29
|
+
SH = "sh"
|
|
30
|
+
DASH = "dash"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ShellSeverity(StrEnum):
|
|
34
|
+
"""Diagnostic thresholds, from the most restrictive to the most inclusive."""
|
|
35
|
+
|
|
36
|
+
ERROR = "error"
|
|
37
|
+
WARNING = "warning"
|
|
38
|
+
INFO = "info"
|
|
39
|
+
STYLE = "style"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
SHELL_TOOL_NAMES: frozenset[str] = frozenset({"bash_validate", "pwsh_validate"})
|
|
43
|
+
SEVERITIES = tuple(ShellSeverity)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class ShellPolicy:
|
|
48
|
+
"""Immutable host policy; inline suppressions remain honored and reported.
|
|
49
|
+
|
|
50
|
+
``default_dialect`` applies only to shebangless .sh files. PowerShell dependencies are
|
|
51
|
+
supplied by the selected image's inventory, never by tool arguments.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
minimum_severity: ShellSeverity = ShellSeverity.STYLE
|
|
55
|
+
excluded_rules: tuple[str, ...] = ()
|
|
56
|
+
default_dialect: ShellDialect = ShellDialect.BASH
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
object.__setattr__(self, "minimum_severity", ShellSeverity(self.minimum_severity))
|
|
60
|
+
object.__setattr__(self, "default_dialect", ShellDialect(self.default_dialect))
|
|
61
|
+
if (
|
|
62
|
+
type(self.excluded_rules) is not tuple
|
|
63
|
+
or len(self.excluded_rules) > 256
|
|
64
|
+
or any(
|
|
65
|
+
type(rule) is not str
|
|
66
|
+
or not re.fullmatch(r"SC[0-9]{4,6}|PS[A-Za-z][A-Za-z0-9]{1,100}", rule)
|
|
67
|
+
for rule in self.excluded_rules
|
|
68
|
+
)
|
|
69
|
+
or len(set(self.excluded_rules)) != len(self.excluded_rules)
|
|
70
|
+
):
|
|
71
|
+
raise ValueError("excluded_rules must be a bounded tuple of distinct rule IDs")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def shell_sandbox_spec(
|
|
75
|
+
image: str | None = None,
|
|
76
|
+
image_id: str | None = None,
|
|
77
|
+
*,
|
|
78
|
+
flavor: ShellFlavor,
|
|
79
|
+
) -> SandboxSpec:
|
|
80
|
+
"""Require a fresh closed-network POSIX sandbox with disposal after every call."""
|
|
81
|
+
return SandboxSpec(
|
|
82
|
+
kind=ShellFlavor(flavor).value,
|
|
83
|
+
image=image,
|
|
84
|
+
image_id=image_id,
|
|
85
|
+
work_dir=None,
|
|
86
|
+
egress=Egress.CLOSED,
|
|
87
|
+
requires=frozenset({Capability.EXEC, Capability.FILES_IN}),
|
|
88
|
+
requires_os_family=OsFamily.POSIX,
|
|
89
|
+
min_isolation=Isolation.CONTAINER,
|
|
90
|
+
isolation_scope=IsolationScope.CALL,
|
|
91
|
+
min_cleanup=Cleanup.DISPOSE,
|
|
92
|
+
confined_to_guest_call_path=False,
|
|
93
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Attach a host-selected analyzer through the shared sandbox session lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from maf_sandbox import CallerContext, SandboxRouter, SourceIntegrity, error_detail
|
|
14
|
+
from maf_sandbox.maf import (
|
|
15
|
+
SandboxResult,
|
|
16
|
+
SandboxToolSession,
|
|
17
|
+
positions_holding_hidden_content,
|
|
18
|
+
sandboxed_tool,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from ._paths import dialect_for, resolve_manifest
|
|
22
|
+
from ._report import EMPTY_INVENTORY_SHA256, report_outcome
|
|
23
|
+
from ._spec import ShellFlavor, ShellPolicy, shell_sandbox_spec
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from agent_framework import AgentFileStore
|
|
27
|
+
from maf_sandbox import FileStoreProvenance
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
VERDICTS = ("clean", "findings")
|
|
31
|
+
STANDING_GUIDANCE = (
|
|
32
|
+
"Static validation does not establish runtime or deployment success. Raw diagnostics, "
|
|
33
|
+
"locations, counts and suppression declarations are untrusted. Use completion and the verdict "
|
|
34
|
+
"to determine the result. Inline suppressions are honored; clean can include suppressed "
|
|
35
|
+
"findings. Trusted summaries use fixed rule descriptions and files[N] references only. "
|
|
36
|
+
"This tool does not execute scripts, install dependencies, rewrite files or update the store."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_shell_tools(
|
|
41
|
+
router: SandboxRouter | None,
|
|
42
|
+
file_store: AgentFileStore,
|
|
43
|
+
agent_id: str,
|
|
44
|
+
context: CallerContext,
|
|
45
|
+
*,
|
|
46
|
+
flavor: ShellFlavor,
|
|
47
|
+
policy: ShellPolicy = ShellPolicy(),
|
|
48
|
+
image: str | None = None,
|
|
49
|
+
image_id: str | None = None,
|
|
50
|
+
exec_timeout_seconds: float = 120,
|
|
51
|
+
dependency_inventory_sha256: str = EMPTY_INVENTORY_SHA256,
|
|
52
|
+
file_store_provenance: FileStoreProvenance | None = None,
|
|
53
|
+
) -> list[Any]:
|
|
54
|
+
"""Enable one flavor; the host fixes policy, image, dependency inventory and shared deadline."""
|
|
55
|
+
flavor = ShellFlavor(flavor)
|
|
56
|
+
if type(policy) is not ShellPolicy:
|
|
57
|
+
raise TypeError("policy must be ShellPolicy")
|
|
58
|
+
if (
|
|
59
|
+
isinstance(exec_timeout_seconds, bool)
|
|
60
|
+
or not math.isfinite(exec_timeout_seconds)
|
|
61
|
+
or not 0 < exec_timeout_seconds <= 600
|
|
62
|
+
):
|
|
63
|
+
raise ValueError("exec_timeout_seconds must be finite and in (0, 600]")
|
|
64
|
+
if not re.fullmatch(r"[a-f0-9]{64}", dependency_inventory_sha256):
|
|
65
|
+
raise ValueError("dependency_inventory_sha256 must be a SHA256 digest")
|
|
66
|
+
return sandboxed_tool(
|
|
67
|
+
lambda session: _build_tool(
|
|
68
|
+
session, file_store, flavor, policy, exec_timeout_seconds, dependency_inventory_sha256
|
|
69
|
+
),
|
|
70
|
+
router=router,
|
|
71
|
+
context=context,
|
|
72
|
+
agent_id=agent_id,
|
|
73
|
+
spec=shell_sandbox_spec(image, image_id, flavor=flavor),
|
|
74
|
+
name=f"{flavor}_validate",
|
|
75
|
+
source_integrity=SourceIntegrity.UNTRUSTED,
|
|
76
|
+
result_contract=True,
|
|
77
|
+
verdicts=VERDICTS,
|
|
78
|
+
standing_guidance=(STANDING_GUIDANCE,),
|
|
79
|
+
file_store_provenance=file_store_provenance,
|
|
80
|
+
admission_timeout=max(30, exec_timeout_seconds),
|
|
81
|
+
logger=logger,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _incomplete(message: str, detail: str | None = None) -> SandboxResult:
|
|
86
|
+
return SandboxResult(
|
|
87
|
+
completed=False, trusted_output=(message,), output=(detail,) if detail else ()
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_tool(
|
|
92
|
+
session: SandboxToolSession,
|
|
93
|
+
store: AgentFileStore,
|
|
94
|
+
flavor: ShellFlavor,
|
|
95
|
+
policy: ShellPolicy,
|
|
96
|
+
timeout: float,
|
|
97
|
+
inventory_sha256: str,
|
|
98
|
+
) -> Callable[..., Awaitable[SandboxResult]]:
|
|
99
|
+
async def validate(files: list[str]) -> SandboxResult:
|
|
100
|
+
"""Parse and lint the explicitly listed shell scripts without executing their commands.
|
|
101
|
+
|
|
102
|
+
Supply store-relative paths. Bash flavor accepts .sh, .bash, .dash and extensionless
|
|
103
|
+
scripts with a supported shebang. PowerShell accepts .ps1 and .psm1. Include sourced
|
|
104
|
+
shell files in this list. PowerShell dependencies must be preinstalled by the host;
|
|
105
|
+
submitted modules are analysis inputs. Inline suppressions are honored and reported.
|
|
106
|
+
"""
|
|
107
|
+
key = session.key()
|
|
108
|
+
if isinstance(key, str):
|
|
109
|
+
return _incomplete(key)
|
|
110
|
+
hidden = positions_holding_hidden_content(files, argument="files")
|
|
111
|
+
limits = session.spec.files_in
|
|
112
|
+
if not files or len(files) > min(64, limits.max_files):
|
|
113
|
+
return _incomplete(
|
|
114
|
+
"Validation incomplete: the manifest is empty or exceeds the file limit."
|
|
115
|
+
)
|
|
116
|
+
listing = await session.list_files(store)
|
|
117
|
+
if isinstance(listing, str):
|
|
118
|
+
return _incomplete(
|
|
119
|
+
"Validation incomplete: the file store could not be listed.", listing
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
selected = resolve_manifest(files, listing, flavor)
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
return _incomplete(f"Validation incomplete: {exc}")
|
|
125
|
+
sources: list[dict[str, str]] = []
|
|
126
|
+
total = 0
|
|
127
|
+
for index, (path, listed) in enumerate(selected):
|
|
128
|
+
item = await session.read_file(
|
|
129
|
+
store, listed, at=f"files[{index}]", hidden=index in hidden, named=f"files[{index}]"
|
|
130
|
+
)
|
|
131
|
+
if isinstance(item, str):
|
|
132
|
+
return _incomplete(item)
|
|
133
|
+
if item is None or item.text is None or "\x00" in item.text:
|
|
134
|
+
return _incomplete("Validation incomplete: every manifest file must contain text.")
|
|
135
|
+
try:
|
|
136
|
+
size = len(item.text.encode("utf-8"))
|
|
137
|
+
dialect = (
|
|
138
|
+
dialect_for(path, item.text, policy.default_dialect)
|
|
139
|
+
if flavor == ShellFlavor.BASH
|
|
140
|
+
else "pwsh"
|
|
141
|
+
)
|
|
142
|
+
except (ValueError, UnicodeError):
|
|
143
|
+
return _incomplete(
|
|
144
|
+
"Validation incomplete: invalid text or conflicting shell declarations."
|
|
145
|
+
)
|
|
146
|
+
total += size
|
|
147
|
+
if size > limits.max_bytes_per_file or total > limits.max_total_bytes:
|
|
148
|
+
return _incomplete(
|
|
149
|
+
"Validation incomplete: the manifest exceeds the transfer byte limits."
|
|
150
|
+
)
|
|
151
|
+
sources.append({"path": path, "text": item.text, "dialect": dialect})
|
|
152
|
+
bundle = json.dumps({"flavor": flavor, "files": sources}, ensure_ascii=True)
|
|
153
|
+
if len(bundle.encode("utf-8")) > min(limits.max_bytes_per_file, 16 * 1024 * 1024):
|
|
154
|
+
return _incomplete(
|
|
155
|
+
"Validation incomplete: the source bundle exceeds the transfer limit."
|
|
156
|
+
)
|
|
157
|
+
sandbox = await session.acquire(key)
|
|
158
|
+
if isinstance(sandbox, str):
|
|
159
|
+
return _incomplete("Validation incomplete: the sandbox could not be acquired.", sandbox)
|
|
160
|
+
guest_call_path = session.guest_call_path()
|
|
161
|
+
try:
|
|
162
|
+
await sandbox.write_file("request.json", bundle, working_directory=guest_call_path)
|
|
163
|
+
execution = asyncio.create_task(
|
|
164
|
+
sandbox.exec(
|
|
165
|
+
["/usr/local/bin/python3", "-I", "/opt/maf-shell/runner.py", str(timeout)],
|
|
166
|
+
working_directory=guest_call_path,
|
|
167
|
+
timeout=timeout + 5,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
try:
|
|
171
|
+
result = await asyncio.shield(execution)
|
|
172
|
+
except asyncio.CancelledError:
|
|
173
|
+
while not execution.done():
|
|
174
|
+
try:
|
|
175
|
+
await asyncio.shield(execution)
|
|
176
|
+
except (asyncio.CancelledError, Exception):
|
|
177
|
+
# Keep the sandbox alive until the shielded execution finishes.
|
|
178
|
+
pass
|
|
179
|
+
if not execution.cancelled():
|
|
180
|
+
execution.exception()
|
|
181
|
+
raise
|
|
182
|
+
if result.exit_code != 0 or result.producer_owns_stderr or result.stderr_bytes:
|
|
183
|
+
return _incomplete(
|
|
184
|
+
"Validation incomplete: the launcher did not return a complete report."
|
|
185
|
+
)
|
|
186
|
+
outcome = report_outcome(
|
|
187
|
+
result.stdout_bytes,
|
|
188
|
+
flavor,
|
|
189
|
+
[path for path, _ in selected],
|
|
190
|
+
policy,
|
|
191
|
+
inventory_sha256,
|
|
192
|
+
hidden=bool(hidden),
|
|
193
|
+
)
|
|
194
|
+
return SandboxResult(
|
|
195
|
+
completed=outcome.completed,
|
|
196
|
+
verdict=outcome.verdict,
|
|
197
|
+
trusted_output=outcome.summary,
|
|
198
|
+
output=(outcome.detail,) if outcome.detail else (),
|
|
199
|
+
)
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
logger.warning("shell validation failed: %s", error_detail(exc))
|
|
202
|
+
return _incomplete(
|
|
203
|
+
"Validation incomplete: staging, execution or report verification failed."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return validate
|
|
File without changes
|