gravity-schema 0.4.4__tar.gz → 0.5.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.
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/PKG-INFO +1 -1
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/__init__.py +6 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/catalog.yaml +16 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/errors.py +2 -0
- gravity_schema-0.5.0/gravity_schema/source_scan.py +83 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/validation.py +23 -2
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/pyproject.toml +1 -1
- gravity_schema-0.5.0/tests/test_source_scan.py +67 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_validation.py +25 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/.gitignore +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/LICENSE +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/NOTICE +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/README.md +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/__main__.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/catalog.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/catalog_imported.yaml +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/contract.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/cron.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/export.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/gen_catalog.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/manifest.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/schema/manifest.schema.json +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/conftest.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_catalog_parity.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_cron.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_errors.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_json_schema.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_kind_outputs.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_manifest.py +0 -0
- {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_slots.py +0 -0
|
@@ -36,7 +36,9 @@ from .manifest import (
|
|
|
36
36
|
StateSpec,
|
|
37
37
|
TriggersSpec,
|
|
38
38
|
)
|
|
39
|
+
from .source_scan import is_agent_source, undeclared_write_tools
|
|
39
40
|
from .validation import (
|
|
41
|
+
check_imported_write_approvals,
|
|
40
42
|
check_tool_references,
|
|
41
43
|
load_manifest_file,
|
|
42
44
|
load_manifest_text,
|
|
@@ -73,6 +75,10 @@ __all__ = [ # noqa: RUF022 - grouped by concern, not alphabetical
|
|
|
73
75
|
"load_manifest_text",
|
|
74
76
|
"load_manifest_file",
|
|
75
77
|
"check_tool_references",
|
|
78
|
+
"check_imported_write_approvals",
|
|
79
|
+
# source scan
|
|
80
|
+
"is_agent_source",
|
|
81
|
+
"undeclared_write_tools",
|
|
76
82
|
# catalog
|
|
77
83
|
"ToolCatalog",
|
|
78
84
|
"StaticToolCatalog",
|
|
@@ -27,6 +27,22 @@
|
|
|
27
27
|
guarantee: certified
|
|
28
28
|
side_effect: read
|
|
29
29
|
|
|
30
|
+
- name: web.search
|
|
31
|
+
slug: COMPOSIO_SEARCH_WEB
|
|
32
|
+
description: search the web; returns results with titles, links and snippets. No account needed
|
|
33
|
+
scopes: []
|
|
34
|
+
breadth: generic
|
|
35
|
+
guarantee: certified
|
|
36
|
+
side_effect: read
|
|
37
|
+
|
|
38
|
+
- name: web.fetch
|
|
39
|
+
slug: COMPOSIO_SEARCH_FETCH_URL_CONTENT
|
|
40
|
+
description: read the text content of a web page by its URL. No account needed
|
|
41
|
+
scopes: []
|
|
42
|
+
breadth: generic
|
|
43
|
+
guarantee: certified
|
|
44
|
+
side_effect: read
|
|
45
|
+
|
|
30
46
|
- name: supadata.get_transcript
|
|
31
47
|
slug: null
|
|
32
48
|
description: fetch the transcript of a YouTube video with the caller's own Supadata API key (api_key argument)
|
|
@@ -27,6 +27,8 @@ class ErrorCode(str, Enum):
|
|
|
27
27
|
INVALID_TOOL_NAME = "INVALID_TOOL_NAME"
|
|
28
28
|
DUPLICATE_TOOL = "DUPLICATE_TOOL"
|
|
29
29
|
TOOL_NOT_FOUND = "TOOL_NOT_FOUND"
|
|
30
|
+
UNDECLARED_TOOL_CALL = "UNDECLARED_TOOL_CALL"
|
|
31
|
+
APPROVAL_REQUIRED = "APPROVAL_REQUIRED"
|
|
30
32
|
INVALID_MODEL_TIER = "INVALID_MODEL_TIER"
|
|
31
33
|
INVALID_RESOURCES = "INVALID_RESOURCES"
|
|
32
34
|
INVALID_TRIGGER = "INVALID_TRIGGER"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Agent code that calls a write tool its manifest does not grant.
|
|
2
|
+
|
|
3
|
+
The gateway refuses such a call at run time (TOOL_NOT_DECLARED). This finds it
|
|
4
|
+
before the build, from the source text alone: nothing is imported or run.
|
|
5
|
+
`gravity validate` and the platform's build step share it, so a builder who
|
|
6
|
+
skips the CLI is still stopped.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
from collections.abc import Iterable, Iterator
|
|
13
|
+
from pathlib import PurePath
|
|
14
|
+
|
|
15
|
+
from .catalog import CATALOG, ToolEntry
|
|
16
|
+
from .errors import ErrorCode, ManifestIssue, Severity
|
|
17
|
+
from .manifest import Manifest
|
|
18
|
+
|
|
19
|
+
# Not shipped as agent behaviour: tests may name any tool to assert it is refused.
|
|
20
|
+
SKIPPED_DIRS = frozenset({".venv", "venv", "tests", "__pycache__", ".gravity"})
|
|
21
|
+
|
|
22
|
+
# Manifests say 'gmail.send'; code says 'gmail_send' (some models reject dots in
|
|
23
|
+
# function names). Either spelling is the same tool.
|
|
24
|
+
_BY_SPELLING: dict[str, ToolEntry] = {
|
|
25
|
+
spelling: e for e in CATALOG for spelling in (e.name, e.name.replace(".", "_"))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_agent_source(path: PurePath) -> bool:
|
|
30
|
+
return path.suffix == ".py" and not SKIPPED_DIRS & set(path.parts)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _docstrings(tree: ast.Module) -> set[int]:
|
|
34
|
+
owners = [tree, *(n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)))]
|
|
35
|
+
return {
|
|
36
|
+
id(owner.body[0].value)
|
|
37
|
+
for owner in owners
|
|
38
|
+
if owner.body
|
|
39
|
+
and isinstance(owner.body[0], ast.Expr)
|
|
40
|
+
and isinstance(owner.body[0].value, ast.Constant)
|
|
41
|
+
and isinstance(owner.body[0].value.value, str)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _string_literals(tree: ast.Module) -> Iterator[str]:
|
|
46
|
+
"""Every string constant except docstrings, which describe rather than call."""
|
|
47
|
+
skip = _docstrings(tree)
|
|
48
|
+
for node in ast.walk(tree):
|
|
49
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str) and id(node) not in skip:
|
|
50
|
+
yield node.value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def undeclared_write_tools(sources: Iterable[tuple[str, str]], manifest: Manifest) -> list[ManifestIssue]:
|
|
54
|
+
"""One issue per catalog write tool named in `sources` ((path, text) pairs)
|
|
55
|
+
that no part of the manifest grants. Reads stay the drift scan's warning."""
|
|
56
|
+
declared = manifest.declared_tools
|
|
57
|
+
first_seen: dict[str, str] = {}
|
|
58
|
+
for path, text in sources:
|
|
59
|
+
try:
|
|
60
|
+
tree = ast.parse(text, filename=path)
|
|
61
|
+
except SyntaxError:
|
|
62
|
+
continue # the entrypoint check reports a broken entrypoint; this scan has nothing to read
|
|
63
|
+
for literal in _string_literals(tree):
|
|
64
|
+
entry = _BY_SPELLING.get(literal)
|
|
65
|
+
if entry and entry.side_effect == "write" and entry.name not in declared:
|
|
66
|
+
first_seen.setdefault(entry.name, path)
|
|
67
|
+
|
|
68
|
+
return [
|
|
69
|
+
ManifestIssue(
|
|
70
|
+
code=ErrorCode.UNDECLARED_TOOL_CALL,
|
|
71
|
+
location=path,
|
|
72
|
+
message=f"{path} calls '{tool}', a write tool the manifest does not grant.",
|
|
73
|
+
remediation=(
|
|
74
|
+
f"Declare '{tool}' under permissions.tools (and approvals.required_for, so a person "
|
|
75
|
+
"says yes before it acts), or remove the call. If it only delivers the finished "
|
|
76
|
+
"result, list it under an output's destinations instead: the platform sends it "
|
|
77
|
+
"from the user's own connected account after the run."
|
|
78
|
+
),
|
|
79
|
+
value=tool,
|
|
80
|
+
severity=Severity.HIGH,
|
|
81
|
+
)
|
|
82
|
+
for tool, path in sorted(first_seen.items())
|
|
83
|
+
]
|
|
@@ -9,7 +9,7 @@ import yaml
|
|
|
9
9
|
from pydantic import ValidationError
|
|
10
10
|
|
|
11
11
|
from . import contract as C
|
|
12
|
-
from .catalog import ToolCatalog
|
|
12
|
+
from .catalog import CATALOG, ToolCatalog
|
|
13
13
|
from .errors import (
|
|
14
14
|
ErrorCode,
|
|
15
15
|
ManifestIssue,
|
|
@@ -56,7 +56,28 @@ def check_tool_references(manifest: Manifest, catalog: ToolCatalog) -> list[Mani
|
|
|
56
56
|
)
|
|
57
57
|
)
|
|
58
58
|
|
|
59
|
-
return issues
|
|
59
|
+
return issues + check_imported_write_approvals(manifest)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Imported tools (catalog_imported.yaml) are unreviewed. The gateway refuses a run that is
|
|
63
|
+
# granted an imported write tool without a human approval in front of it (APPROVAL_REQUIRED).
|
|
64
|
+
_IMPORTED_WRITES = frozenset(e.name for e in CATALOG if e.guarantee == "experimental" and e.side_effect == "write")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_imported_write_approvals(manifest: Manifest) -> list[ManifestIssue]:
|
|
68
|
+
"""One issue per imported write tool the agent may call that no approval covers."""
|
|
69
|
+
gated = set(manifest.approvals.required_for)
|
|
70
|
+
return [
|
|
71
|
+
ManifestIssue(
|
|
72
|
+
code=ErrorCode.APPROVAL_REQUIRED,
|
|
73
|
+
location="approvals.required_for",
|
|
74
|
+
message=f"'{name}' is an imported write tool, so a person must approve each call.",
|
|
75
|
+
remediation=f"Add '{name}' under approvals.required_for, with a fallback for when it isn't approved.",
|
|
76
|
+
value=name,
|
|
77
|
+
severity=Severity.HIGH,
|
|
78
|
+
)
|
|
79
|
+
for name in sorted(manifest.declared_tools & _IMPORTED_WRITES - gated)
|
|
80
|
+
]
|
|
60
81
|
|
|
61
82
|
|
|
62
83
|
def validate_manifest(
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from gravity_schema import Manifest, is_agent_source, undeclared_write_tools
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def manifest(tools: list[str], adapters: dict[str, list[str]] | None = None) -> Manifest:
|
|
10
|
+
data = {
|
|
11
|
+
"name": "scan-agent",
|
|
12
|
+
"version": "1.0.0",
|
|
13
|
+
"runtime": {"python": "3.12", "entrypoint": "src/agent.py:graph"},
|
|
14
|
+
"permissions": {"tools": tools},
|
|
15
|
+
}
|
|
16
|
+
if adapters:
|
|
17
|
+
data["slots"] = {"deliver": {"label": "Deliver to", "default": next(iter(adapters)), "adapters": adapters}}
|
|
18
|
+
return Manifest.model_validate(data)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def found(source: str, m: Manifest) -> list[str]:
|
|
22
|
+
return [i.value for i in undeclared_write_tools([("src/agent.py", source)], m)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize("spelling", ["gmail.send", "gmail_send"])
|
|
26
|
+
def test_an_undeclared_send_is_found_in_either_spelling(spelling):
|
|
27
|
+
assert found(f'TOOL = "{spelling}"\n', manifest(["gmail.read"])) == ["gmail.send"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_a_declared_send_is_not_reported():
|
|
31
|
+
assert found('TOOL = "gmail_send"\n', manifest(["gmail.send"])) == []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_a_tool_an_adapter_declares_is_granted():
|
|
35
|
+
m = manifest([], {"slack": ["slack.send"]})
|
|
36
|
+
assert found('TOOL = "slack_send"\n', m) == []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_reads_are_left_to_the_drift_warning():
|
|
40
|
+
assert found('TOOL = "gmail_read"\n', manifest([])) == []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_docstrings_describe_rather_than_call():
|
|
44
|
+
source = '"""Never "gmail.send"."""\n\n\ndef f():\n """Uses gmail_send? No."""\n return 1\n'
|
|
45
|
+
assert found(source, manifest([])) == []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_each_tool_is_reported_once_with_where_it_was_first_seen():
|
|
49
|
+
sources = [("src/a.py", 'X = "slack.send"\n'), ("src/b.py", 'Y = "slack_send"\nZ = "gmail.send"\n')]
|
|
50
|
+
issues = undeclared_write_tools(sources, manifest([]))
|
|
51
|
+
assert [(i.value, i.location, i.code.value) for i in issues] == [
|
|
52
|
+
("gmail.send", "src/b.py", "UNDECLARED_TOOL_CALL"),
|
|
53
|
+
("slack.send", "src/a.py", "UNDECLARED_TOOL_CALL"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_a_file_that_does_not_parse_is_skipped():
|
|
58
|
+
assert found("def broken(:\n 'gmail.send'\n", manifest([])) == []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize(
|
|
62
|
+
("path", "shipped"),
|
|
63
|
+
[("src/agent.py", True), ("agent.py", True), ("tests/test_x.py", False),
|
|
64
|
+
(".venv/lib/x.py", False), ("src/__pycache__/x.py", False), ("README.md", False)],
|
|
65
|
+
)
|
|
66
|
+
def test_only_shipped_python_is_scanned(path, shipped):
|
|
67
|
+
assert is_agent_source(PurePosixPath(path)) is shipped
|
|
@@ -170,3 +170,28 @@ def test_missing_file_reports_clearly(tmp_path):
|
|
|
170
170
|
|
|
171
171
|
assert "gravity init" in (ei.value.issues[0].remediation or "")
|
|
172
172
|
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_an_imported_write_tool_needs_an_approval(manifest_data, catalog):
|
|
176
|
+
"""The gateway refuses a run granted an unreviewed write tool without a human approval."""
|
|
177
|
+
manifest_data["permissions"]["tools"] = ["gmail.read", "github.add_assignees_to_an_issue"]
|
|
178
|
+
with pytest.raises(ManifestValidationError) as e:
|
|
179
|
+
validate_manifest(manifest_data, catalog=catalog)
|
|
180
|
+
issues = [i for i in e.value.issues if i.code == ErrorCode.APPROVAL_REQUIRED]
|
|
181
|
+
assert [i.value for i in issues] == ["github.add_assignees_to_an_issue"]
|
|
182
|
+
|
|
183
|
+
manifest_data["approvals"] = {"required_for": ["github.add_assignees_to_an_issue"]}
|
|
184
|
+
validate_manifest(manifest_data, catalog=catalog)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_curated_writes_and_imported_reads_need_no_approval(manifest_data, catalog):
|
|
188
|
+
manifest_data["permissions"]["tools"] = ["gmail.send", "github.get_a_repository", "web.search", "web.fetch"]
|
|
189
|
+
manifest_data.pop("approvals", None)
|
|
190
|
+
validate_manifest(manifest_data, catalog=catalog)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_a_tool_in_no_catalog_is_refused(manifest_data, catalog):
|
|
194
|
+
manifest_data["permissions"]["tools"] = ["composio_search.web"]
|
|
195
|
+
with pytest.raises(ManifestValidationError) as e:
|
|
196
|
+
validate_manifest(manifest_data, catalog=catalog)
|
|
197
|
+
assert {i.code for i in e.value.issues} == {ErrorCode.TOOL_NOT_FOUND}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|