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.
Files changed (30) hide show
  1. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/PKG-INFO +1 -1
  2. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/__init__.py +6 -0
  3. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/catalog.yaml +16 -0
  4. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/errors.py +2 -0
  5. gravity_schema-0.5.0/gravity_schema/source_scan.py +83 -0
  6. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/validation.py +23 -2
  7. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/pyproject.toml +1 -1
  8. gravity_schema-0.5.0/tests/test_source_scan.py +67 -0
  9. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_validation.py +25 -0
  10. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/.gitignore +0 -0
  11. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/LICENSE +0 -0
  12. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/NOTICE +0 -0
  13. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/README.md +0 -0
  14. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/__main__.py +0 -0
  15. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/catalog.py +0 -0
  16. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/catalog_imported.yaml +0 -0
  17. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/contract.py +0 -0
  18. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/cron.py +0 -0
  19. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/export.py +0 -0
  20. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/gen_catalog.py +0 -0
  21. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/gravity_schema/manifest.py +0 -0
  22. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/schema/manifest.schema.json +0 -0
  23. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/conftest.py +0 -0
  24. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_catalog_parity.py +0 -0
  25. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_cron.py +0 -0
  26. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_errors.py +0 -0
  27. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_json_schema.py +0 -0
  28. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_kind_outputs.py +0 -0
  29. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_manifest.py +0 -0
  30. {gravity_schema-0.4.4 → gravity_schema-0.5.0}/tests/test_slots.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gravity-schema
3
- Version: 0.4.4
3
+ Version: 0.5.0
4
4
  Summary: Gravity agent manifest contract — models, validation, and JSON Schema export
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -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(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gravity-schema"
3
- version = "0.4.4"
3
+ version = "0.5.0"
4
4
  description = "Gravity agent manifest contract — models, validation, and JSON Schema export"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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