dent8 0.6.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.
- dent8-0.6.0/.gitignore +40 -0
- dent8-0.6.0/PKG-INFO +78 -0
- dent8-0.6.0/README.md +62 -0
- dent8-0.6.0/pyproject.toml +26 -0
- dent8-0.6.0/src/dent8/__init__.py +331 -0
- dent8-0.6.0/tests/test_client.py +137 -0
dent8-0.6.0/.gitignore
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/target/
|
|
2
|
+
|
|
3
|
+
# Local runtime configuration.
|
|
4
|
+
.env
|
|
5
|
+
.env.*
|
|
6
|
+
!.env.example
|
|
7
|
+
|
|
8
|
+
# Project-local dogfood/adoption state. These contain machine-local paths, grants,
|
|
9
|
+
# private source keys, local stores, and agent MCP config.
|
|
10
|
+
.dent8/
|
|
11
|
+
.dent8
|
|
12
|
+
.codex/
|
|
13
|
+
.cursor/
|
|
14
|
+
.grok/
|
|
15
|
+
.claude/*
|
|
16
|
+
!.claude/settings.json
|
|
17
|
+
/.mcp.json
|
|
18
|
+
|
|
19
|
+
# The CLI's default file-backed event log (DENT8_LOG) and authority registry (DENT8_AUTHORITY).
|
|
20
|
+
/dent8-log.jsonl
|
|
21
|
+
dent8-log.jsonl
|
|
22
|
+
/dent8-authority.json
|
|
23
|
+
dent8-authority.json
|
|
24
|
+
|
|
25
|
+
# Witness keys + signed-tree-head log (DENT8_WITNESS_KEY / DENT8_WITNESS_LOG).
|
|
26
|
+
dent8-witness.key
|
|
27
|
+
dent8-witness.key.pub
|
|
28
|
+
dent8-witness.jsonl
|
|
29
|
+
|
|
30
|
+
# The `dent8 export` default Parquet output (--features export).
|
|
31
|
+
/dent8-events.parquet
|
|
32
|
+
dent8-events.parquet
|
|
33
|
+
|
|
34
|
+
# Python bytecode (from running the example scripts / tooling).
|
|
35
|
+
__pycache__/
|
|
36
|
+
*.pyc
|
|
37
|
+
|
|
38
|
+
# Editor and OS noise.
|
|
39
|
+
.DS_Store
|
|
40
|
+
*.swp
|
dent8-0.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dent8
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: A memory firewall for coding agents — thin Python SDK over the dent8 CLI's JSON contract.
|
|
5
|
+
Project-URL: Homepage, https://github.com/xyzzylabs/dent8
|
|
6
|
+
Project-URL: Changelog, https://github.com/xyzzylabs/dent8/blob/main/CHANGELOG.md
|
|
7
|
+
Author: xyzzylabs
|
|
8
|
+
License-Expression: Apache-2.0 OR MIT
|
|
9
|
+
Keywords: agent,firewall,mcp,memory,provenance
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# dent8 (Python)
|
|
18
|
+
|
|
19
|
+
A memory firewall for coding agents — the thin Python SDK.
|
|
20
|
+
|
|
21
|
+
Every call runs the [`dent8`](https://github.com/xyzzylabs/dent8) binary with
|
|
22
|
+
`--output json` and returns the parsed payload as a `dict`. The wire contract is
|
|
23
|
+
the product: each payload carries `schema_version`; every error carries a stable
|
|
24
|
+
`status` plus a machine-readable `code` (`insufficient-authority`,
|
|
25
|
+
`authority-ceiling`, `content-rejected`, …), raised as `Dent8Rejected` /
|
|
26
|
+
`Dent8Invalid` so you branch on `error.code`, never on prose.
|
|
27
|
+
|
|
28
|
+
Because the CLI resolves the store itself (repo-confined `.dent8/` discovery,
|
|
29
|
+
`DENT8_LOG` / `DENT8_STORE_URL`), routes writes through a local daemon when
|
|
30
|
+
`DENT8_DAEMON_SOCKET` is set, and defaults `--source`/`--authority` from the
|
|
31
|
+
active signed grant, the SDK inherits all of it with zero configuration.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install dent8
|
|
37
|
+
cargo install dent8-cli --locked # the binary the SDK drives
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from dent8 import Dent8, Dent8Rejected
|
|
44
|
+
|
|
45
|
+
d8 = Dent8() # finds `dent8` on PATH; Dent8(binary=..., env={"DENT8_LOG": ...}) to pin
|
|
46
|
+
|
|
47
|
+
d8.assert_fact("repo:myproj", "database", "postgres",
|
|
48
|
+
authority="high", source="user:alice")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
d8.supersede("repo:myproj", "database", "mysql",
|
|
52
|
+
authority="low", source="web:scrape")
|
|
53
|
+
except Dent8Rejected as rejection:
|
|
54
|
+
assert rejection.code == "insufficient-authority" # branch on the code
|
|
55
|
+
|
|
56
|
+
fact = d8.explain("repo:myproj", "database")
|
|
57
|
+
assert fact["value"]["text"] == "postgres" # the firewall held
|
|
58
|
+
|
|
59
|
+
report = d8.verify() # findings are a result: status ok | integrity_issues
|
|
60
|
+
disputes = d8.conflicts() # status contested when live disputes exist
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The belief surface maps 1:1 onto the CLI: `assert_fact` (Python keyword), `supersede`,
|
|
64
|
+
`contradict`, `retract`, `reinforce`, `expire`, `derive(basis=(subject, predicate))`,
|
|
65
|
+
`explain`, `replay`, `facts`, `verify`, `conflicts`. Temporal keywords accept the CLI's
|
|
66
|
+
whole grammar — unix millis, `"now"`, `"-7d"`, RFC 3339, or a bare UTC date.
|
|
67
|
+
|
|
68
|
+
For LLM tool-calling agents, prefer the MCP server (`dent8 mcp serve`) — see
|
|
69
|
+
[examples/langchain](https://github.com/xyzzylabs/dent8/tree/main/examples/langchain) and
|
|
70
|
+
[examples/vercel-ai-sdk](https://github.com/xyzzylabs/dent8/tree/main/examples/vercel-ai-sdk). This SDK is for
|
|
71
|
+
*programmatic* access from Python code.
|
|
72
|
+
|
|
73
|
+
## Test
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
cargo build -p dent8-cli
|
|
77
|
+
DENT8_BIN=../../target/debug/dent8 python -m pytest
|
|
78
|
+
```
|
dent8-0.6.0/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# dent8 (Python)
|
|
2
|
+
|
|
3
|
+
A memory firewall for coding agents — the thin Python SDK.
|
|
4
|
+
|
|
5
|
+
Every call runs the [`dent8`](https://github.com/xyzzylabs/dent8) binary with
|
|
6
|
+
`--output json` and returns the parsed payload as a `dict`. The wire contract is
|
|
7
|
+
the product: each payload carries `schema_version`; every error carries a stable
|
|
8
|
+
`status` plus a machine-readable `code` (`insufficient-authority`,
|
|
9
|
+
`authority-ceiling`, `content-rejected`, …), raised as `Dent8Rejected` /
|
|
10
|
+
`Dent8Invalid` so you branch on `error.code`, never on prose.
|
|
11
|
+
|
|
12
|
+
Because the CLI resolves the store itself (repo-confined `.dent8/` discovery,
|
|
13
|
+
`DENT8_LOG` / `DENT8_STORE_URL`), routes writes through a local daemon when
|
|
14
|
+
`DENT8_DAEMON_SOCKET` is set, and defaults `--source`/`--authority` from the
|
|
15
|
+
active signed grant, the SDK inherits all of it with zero configuration.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pip install dent8
|
|
21
|
+
cargo install dent8-cli --locked # the binary the SDK drives
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Use
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from dent8 import Dent8, Dent8Rejected
|
|
28
|
+
|
|
29
|
+
d8 = Dent8() # finds `dent8` on PATH; Dent8(binary=..., env={"DENT8_LOG": ...}) to pin
|
|
30
|
+
|
|
31
|
+
d8.assert_fact("repo:myproj", "database", "postgres",
|
|
32
|
+
authority="high", source="user:alice")
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
d8.supersede("repo:myproj", "database", "mysql",
|
|
36
|
+
authority="low", source="web:scrape")
|
|
37
|
+
except Dent8Rejected as rejection:
|
|
38
|
+
assert rejection.code == "insufficient-authority" # branch on the code
|
|
39
|
+
|
|
40
|
+
fact = d8.explain("repo:myproj", "database")
|
|
41
|
+
assert fact["value"]["text"] == "postgres" # the firewall held
|
|
42
|
+
|
|
43
|
+
report = d8.verify() # findings are a result: status ok | integrity_issues
|
|
44
|
+
disputes = d8.conflicts() # status contested when live disputes exist
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The belief surface maps 1:1 onto the CLI: `assert_fact` (Python keyword), `supersede`,
|
|
48
|
+
`contradict`, `retract`, `reinforce`, `expire`, `derive(basis=(subject, predicate))`,
|
|
49
|
+
`explain`, `replay`, `facts`, `verify`, `conflicts`. Temporal keywords accept the CLI's
|
|
50
|
+
whole grammar — unix millis, `"now"`, `"-7d"`, RFC 3339, or a bare UTC date.
|
|
51
|
+
|
|
52
|
+
For LLM tool-calling agents, prefer the MCP server (`dent8 mcp serve`) — see
|
|
53
|
+
[examples/langchain](https://github.com/xyzzylabs/dent8/tree/main/examples/langchain) and
|
|
54
|
+
[examples/vercel-ai-sdk](https://github.com/xyzzylabs/dent8/tree/main/examples/vercel-ai-sdk). This SDK is for
|
|
55
|
+
*programmatic* access from Python code.
|
|
56
|
+
|
|
57
|
+
## Test
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
cargo build -p dent8-cli
|
|
61
|
+
DENT8_BIN=../../target/debug/dent8 python -m pytest
|
|
62
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dent8"
|
|
7
|
+
version = "0.6.0"
|
|
8
|
+
description = "A memory firewall for coding agents — thin Python SDK over the dent8 CLI's JSON contract."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0 OR MIT"
|
|
12
|
+
authors = [{ name = "xyzzylabs" }]
|
|
13
|
+
keywords = ["agent", "memory", "provenance", "firewall", "mcp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/xyzzylabs/dent8"
|
|
23
|
+
Changelog = "https://github.com/xyzzylabs/dent8/blob/main/CHANGELOG.md"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/dent8"]
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""dent8 — a memory firewall for coding agents, from Python.
|
|
2
|
+
|
|
3
|
+
A deliberately thin SDK: every call runs the ``dent8`` binary with ``--output json``
|
|
4
|
+
and returns the parsed payload as a ``dict``. The wire contract is the product —
|
|
5
|
+
each payload carries ``schema_version``; every error carries a stable ``status``
|
|
6
|
+
(``rejected`` / ``invalid``) and a machine-readable ``code``
|
|
7
|
+
(``insufficient-authority``, ``authority-ceiling``, ``content-rejected``, …), raised
|
|
8
|
+
here as :class:`Dent8Rejected` / :class:`Dent8Invalid` so callers branch on
|
|
9
|
+
``error.code`` instead of parsing prose.
|
|
10
|
+
|
|
11
|
+
Because the CLI itself resolves the store (repo-confined ``.dent8/`` discovery,
|
|
12
|
+
``DENT8_LOG`` / ``DENT8_STORE_URL`` env), routes writes through a local daemon when
|
|
13
|
+
``DENT8_DAEMON_SOCKET`` is set, and defaults ``--source`` / ``--authority`` from the
|
|
14
|
+
active signed grant, this SDK inherits all of that for free.
|
|
15
|
+
|
|
16
|
+
from dent8 import Dent8
|
|
17
|
+
|
|
18
|
+
d8 = Dent8()
|
|
19
|
+
d8.assert_fact("repo:myproj", "database", "postgres",
|
|
20
|
+
authority="high", source="user:alice")
|
|
21
|
+
fact = d8.explain("repo:myproj", "database")
|
|
22
|
+
assert fact["status"] == "ok"
|
|
23
|
+
|
|
24
|
+
Requires the ``dent8`` binary on ``PATH`` (``cargo install dent8-cli --locked``) or an
|
|
25
|
+
explicit ``Dent8(binary=...)``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import subprocess
|
|
33
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Dent8",
|
|
37
|
+
"Dent8Error",
|
|
38
|
+
"Dent8Invalid",
|
|
39
|
+
"Dent8Rejected",
|
|
40
|
+
"SCHEMA_VERSION",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
#: The JSON output shape this SDK was written against (the CLI stamps it on every payload).
|
|
44
|
+
SCHEMA_VERSION = 1
|
|
45
|
+
|
|
46
|
+
#: A wall-clock instant, in any grammar the CLI accepts: unix millis (int), ``"now"``,
|
|
47
|
+
#: a ±duration offset (``"-7d"``), RFC 3339, or a bare UTC date/datetime.
|
|
48
|
+
Time = Union[int, str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Dent8Error(Exception):
|
|
52
|
+
"""A failed dent8 operation. ``status`` says what happened (``rejected`` /
|
|
53
|
+
``invalid``), ``code`` says why (stable kebab-case, e.g.
|
|
54
|
+
``insufficient-authority``), ``payload`` is the full JSON error object."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, payload: Dict[str, Any]) -> None:
|
|
57
|
+
self.payload = payload
|
|
58
|
+
self.status: str = payload.get("status", "failed")
|
|
59
|
+
self.code: str = payload.get("code", "operation-failed")
|
|
60
|
+
self.message: str = payload.get("message", payload.get("error_reason", ""))
|
|
61
|
+
super().__init__(f"[{self.code}] {self.message}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Dent8Rejected(Dent8Error):
|
|
65
|
+
"""The firewall (or a write-boundary gate) refused a well-formed request."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Dent8Invalid(Dent8Error):
|
|
69
|
+
"""The request was malformed or the configuration unusable."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Dent8:
|
|
73
|
+
"""A handle on the dent8 belief surface, one subprocess per call.
|
|
74
|
+
|
|
75
|
+
:param binary: path to the ``dent8`` binary (default: ``dent8`` on ``PATH``, or
|
|
76
|
+
the ``DENT8_BIN`` environment variable when set).
|
|
77
|
+
:param cwd: working directory for store discovery (default: the process cwd).
|
|
78
|
+
:param env: extra environment entries merged over ``os.environ`` — e.g.
|
|
79
|
+
``{"DENT8_LOG": "/tmp/memory.jsonl"}`` to pin a store instead of discovery.
|
|
80
|
+
:param timeout: per-call subprocess timeout in seconds.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
binary: Optional[str] = None,
|
|
86
|
+
*,
|
|
87
|
+
cwd: Optional[str] = None,
|
|
88
|
+
env: Optional[Dict[str, str]] = None,
|
|
89
|
+
timeout: float = 60.0,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.binary = binary or os.environ.get("DENT8_BIN", "dent8")
|
|
92
|
+
self.cwd = cwd
|
|
93
|
+
self.env = env or {}
|
|
94
|
+
self.timeout = timeout
|
|
95
|
+
|
|
96
|
+
# ---- writes ---------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def assert_fact(
|
|
99
|
+
self,
|
|
100
|
+
subject: str,
|
|
101
|
+
predicate: str,
|
|
102
|
+
value: str,
|
|
103
|
+
*,
|
|
104
|
+
authority: Optional[str] = None,
|
|
105
|
+
source: Optional[str] = None,
|
|
106
|
+
valid_from: Optional[Time] = None,
|
|
107
|
+
valid_to: Optional[Time] = None,
|
|
108
|
+
ttl: Optional[str] = None,
|
|
109
|
+
) -> Dict[str, Any]:
|
|
110
|
+
"""Assert a fact through the firewall. Named ``assert_fact`` because
|
|
111
|
+
``assert`` is a Python keyword; every other verb matches the CLI 1:1."""
|
|
112
|
+
return self._run(
|
|
113
|
+
"assert",
|
|
114
|
+
subject,
|
|
115
|
+
predicate,
|
|
116
|
+
value,
|
|
117
|
+
authority=authority,
|
|
118
|
+
source=source,
|
|
119
|
+
valid_from=valid_from,
|
|
120
|
+
valid_to=valid_to,
|
|
121
|
+
ttl=ttl,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def supersede(
|
|
125
|
+
self,
|
|
126
|
+
subject: str,
|
|
127
|
+
predicate: str,
|
|
128
|
+
value: str,
|
|
129
|
+
*,
|
|
130
|
+
authority: Optional[str] = None,
|
|
131
|
+
source: Optional[str] = None,
|
|
132
|
+
valid_from: Optional[Time] = None,
|
|
133
|
+
valid_to: Optional[Time] = None,
|
|
134
|
+
ttl: Optional[str] = None,
|
|
135
|
+
) -> Dict[str, Any]:
|
|
136
|
+
"""Revise the believed fact via the sanctioned supersession path."""
|
|
137
|
+
return self._run(
|
|
138
|
+
"supersede",
|
|
139
|
+
subject,
|
|
140
|
+
predicate,
|
|
141
|
+
value,
|
|
142
|
+
authority=authority,
|
|
143
|
+
source=source,
|
|
144
|
+
valid_from=valid_from,
|
|
145
|
+
valid_to=valid_to,
|
|
146
|
+
ttl=ttl,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def contradict(
|
|
150
|
+
self,
|
|
151
|
+
subject: str,
|
|
152
|
+
predicate: str,
|
|
153
|
+
value: str,
|
|
154
|
+
*,
|
|
155
|
+
authority: Optional[str] = None,
|
|
156
|
+
source: Optional[str] = None,
|
|
157
|
+
valid_from: Optional[Time] = None,
|
|
158
|
+
valid_to: Optional[Time] = None,
|
|
159
|
+
ttl: Optional[str] = None,
|
|
160
|
+
) -> Dict[str, Any]:
|
|
161
|
+
"""Record dissent: keep both facts, mark the pair contested."""
|
|
162
|
+
return self._run(
|
|
163
|
+
"contradict",
|
|
164
|
+
subject,
|
|
165
|
+
predicate,
|
|
166
|
+
value,
|
|
167
|
+
authority=authority,
|
|
168
|
+
source=source,
|
|
169
|
+
valid_from=valid_from,
|
|
170
|
+
valid_to=valid_to,
|
|
171
|
+
ttl=ttl,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def retract(
|
|
175
|
+
self,
|
|
176
|
+
subject: str,
|
|
177
|
+
predicate: str,
|
|
178
|
+
*,
|
|
179
|
+
authority: Optional[str] = None,
|
|
180
|
+
source: Optional[str] = None,
|
|
181
|
+
) -> Dict[str, Any]:
|
|
182
|
+
"""Retract the believed fact (terminal; taints derivatives)."""
|
|
183
|
+
return self._run("retract", subject, predicate, authority=authority, source=source)
|
|
184
|
+
|
|
185
|
+
def reinforce(
|
|
186
|
+
self,
|
|
187
|
+
subject: str,
|
|
188
|
+
predicate: str,
|
|
189
|
+
*,
|
|
190
|
+
authority: Optional[str] = None,
|
|
191
|
+
source: Optional[str] = None,
|
|
192
|
+
) -> Dict[str, Any]:
|
|
193
|
+
"""Corroborate the believed fact from another source (earned entrenchment)."""
|
|
194
|
+
return self._run("reinforce", subject, predicate, authority=authority, source=source)
|
|
195
|
+
|
|
196
|
+
def expire(
|
|
197
|
+
self,
|
|
198
|
+
subject: str,
|
|
199
|
+
predicate: str,
|
|
200
|
+
*,
|
|
201
|
+
authority: Optional[str] = None,
|
|
202
|
+
source: Optional[str] = None,
|
|
203
|
+
) -> Dict[str, Any]:
|
|
204
|
+
"""Expire the believed fact (terminal)."""
|
|
205
|
+
return self._run("expire", subject, predicate, authority=authority, source=source)
|
|
206
|
+
|
|
207
|
+
def derive(
|
|
208
|
+
self,
|
|
209
|
+
subject: str,
|
|
210
|
+
predicate: str,
|
|
211
|
+
value: str,
|
|
212
|
+
*,
|
|
213
|
+
basis: Tuple[str, str],
|
|
214
|
+
authority: Optional[str] = None,
|
|
215
|
+
source: Optional[str] = None,
|
|
216
|
+
valid_from: Optional[Time] = None,
|
|
217
|
+
valid_to: Optional[Time] = None,
|
|
218
|
+
ttl: Optional[str] = None,
|
|
219
|
+
) -> Dict[str, Any]:
|
|
220
|
+
"""Assert a fact derived from ``basis = (subject, predicate)``, recording the
|
|
221
|
+
dependency edge — if the basis is later retracted, this derivative is flagged
|
|
222
|
+
tainted by ``verify``."""
|
|
223
|
+
basis_subject, basis_predicate = basis
|
|
224
|
+
return self._run(
|
|
225
|
+
"derive",
|
|
226
|
+
subject,
|
|
227
|
+
predicate,
|
|
228
|
+
value,
|
|
229
|
+
"--basis",
|
|
230
|
+
basis_subject,
|
|
231
|
+
basis_predicate,
|
|
232
|
+
authority=authority,
|
|
233
|
+
source=source,
|
|
234
|
+
valid_from=valid_from,
|
|
235
|
+
valid_to=valid_to,
|
|
236
|
+
ttl=ttl,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# ---- reads / audit ----------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def explain(
|
|
242
|
+
self,
|
|
243
|
+
subject: str,
|
|
244
|
+
predicate: str,
|
|
245
|
+
*,
|
|
246
|
+
as_of: Optional[Time] = None,
|
|
247
|
+
valid_at: Optional[Time] = None,
|
|
248
|
+
) -> Dict[str, Any]:
|
|
249
|
+
"""The believed (or terminal) fact with its integrity receipt; time-travel
|
|
250
|
+
with ``as_of`` / ``valid_at``."""
|
|
251
|
+
return self._run("explain", subject, predicate, as_of=as_of, valid_at=valid_at)
|
|
252
|
+
|
|
253
|
+
def replay(
|
|
254
|
+
self,
|
|
255
|
+
subject: str,
|
|
256
|
+
predicate: str,
|
|
257
|
+
*,
|
|
258
|
+
as_of: Optional[Time] = None,
|
|
259
|
+
valid_at: Optional[Time] = None,
|
|
260
|
+
) -> Dict[str, Any]:
|
|
261
|
+
"""The full event history behind a fact — why it is believed."""
|
|
262
|
+
return self._run("replay", subject, predicate, as_of=as_of, valid_at=valid_at)
|
|
263
|
+
|
|
264
|
+
def facts(self, *, include_diagnostics: bool = False) -> Dict[str, Any]:
|
|
265
|
+
"""Every known fact stream, with freshness flags."""
|
|
266
|
+
args: List[str] = ["facts", "list"]
|
|
267
|
+
if include_diagnostics:
|
|
268
|
+
args.append("--include-diagnostics")
|
|
269
|
+
return self._run(*args)
|
|
270
|
+
|
|
271
|
+
def verify(self) -> Dict[str, Any]:
|
|
272
|
+
"""Integrity checks: hash chain, lineage, taint, attestations. Findings are a
|
|
273
|
+
*result*, not an exception: the payload's ``status`` is ``ok`` or
|
|
274
|
+
``integrity_issues`` (mirroring the MCP tool). Only a malformed invocation
|
|
275
|
+
raises."""
|
|
276
|
+
try:
|
|
277
|
+
return self._run("verify")
|
|
278
|
+
except Dent8Rejected as error:
|
|
279
|
+
return error.payload
|
|
280
|
+
|
|
281
|
+
def conflicts(self) -> Dict[str, Any]:
|
|
282
|
+
"""Contested facts. ``status`` is ``contested`` when disputes exist, ``ok``
|
|
283
|
+
when none do."""
|
|
284
|
+
return self._run("conflicts")
|
|
285
|
+
|
|
286
|
+
# ---- plumbing ---------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def _run(self, *args: str, **flags: Optional[Union[Time, str]]) -> Dict[str, Any]:
|
|
289
|
+
command: List[str] = [self.binary, "--output", "json", *args]
|
|
290
|
+
for name, value in flags.items():
|
|
291
|
+
if value is None:
|
|
292
|
+
continue
|
|
293
|
+
command.append("--" + name.replace("_", "-"))
|
|
294
|
+
command.append(str(value))
|
|
295
|
+
completed = subprocess.run(
|
|
296
|
+
command,
|
|
297
|
+
capture_output=True,
|
|
298
|
+
text=True,
|
|
299
|
+
timeout=self.timeout,
|
|
300
|
+
cwd=self.cwd,
|
|
301
|
+
env={**os.environ, **self.env},
|
|
302
|
+
check=False,
|
|
303
|
+
)
|
|
304
|
+
payload = self._parse(completed, command)
|
|
305
|
+
if completed.returncode == 0:
|
|
306
|
+
return payload
|
|
307
|
+
raise (Dent8Invalid if completed.returncode == 2 else Dent8Rejected)(payload)
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _parse(
|
|
311
|
+
completed: "subprocess.CompletedProcess[str]", command: Sequence[str]
|
|
312
|
+
) -> Dict[str, Any]:
|
|
313
|
+
# The machine contract: every --output json result — success and error — is one
|
|
314
|
+
# JSON object on stdout. Anything else (e.g. a clap usage error on stderr) is
|
|
315
|
+
# surfaced as invalid with the raw text preserved.
|
|
316
|
+
text = completed.stdout.strip()
|
|
317
|
+
if text:
|
|
318
|
+
try:
|
|
319
|
+
payload = json.loads(text)
|
|
320
|
+
if isinstance(payload, dict):
|
|
321
|
+
return payload
|
|
322
|
+
except json.JSONDecodeError:
|
|
323
|
+
pass
|
|
324
|
+
raise Dent8Invalid(
|
|
325
|
+
{
|
|
326
|
+
"status": "invalid",
|
|
327
|
+
"code": "invalid-argument",
|
|
328
|
+
"message": (completed.stderr or completed.stdout or "").strip()
|
|
329
|
+
or f"no JSON output from: {' '.join(command)}",
|
|
330
|
+
}
|
|
331
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""End-to-end tests against the real dent8 binary (the SDK is a thin wrapper, so the
|
|
2
|
+
binary IS the unit under test). Point DENT8_BIN at a built binary, or have `dent8` on
|
|
3
|
+
PATH; the suite skips cleanly otherwise:
|
|
4
|
+
|
|
5
|
+
cargo build -p dent8-cli
|
|
6
|
+
DENT8_BIN=../../target/debug/dent8 python -m pytest
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from dent8 import SCHEMA_VERSION, Dent8, Dent8Invalid, Dent8Rejected
|
|
18
|
+
|
|
19
|
+
BINARY = os.environ.get("DENT8_BIN") or shutil.which("dent8")
|
|
20
|
+
|
|
21
|
+
pytestmark = pytest.mark.skipif(
|
|
22
|
+
BINARY is None, reason="no dent8 binary (set DENT8_BIN or install dent8-cli)"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture()
|
|
27
|
+
def d8(tmp_path, monkeypatch):
|
|
28
|
+
"""A client pinned to a throwaway store in permissive dev mode: every ambient
|
|
29
|
+
DENT8_* variable is scrubbed (the developer's own shell may carry a dogfood store
|
|
30
|
+
with identity enforcement), then the store paths are pinned to the tmp dir."""
|
|
31
|
+
for key in list(os.environ):
|
|
32
|
+
if key.startswith("DENT8_"):
|
|
33
|
+
monkeypatch.delenv(key)
|
|
34
|
+
env = {
|
|
35
|
+
"DENT8_LOG": str(tmp_path / "memory.jsonl"),
|
|
36
|
+
"DENT8_AUTHORITY": str(tmp_path / "authority.json"),
|
|
37
|
+
}
|
|
38
|
+
return Dent8(binary=BINARY, cwd=str(tmp_path), env=env)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_assert_explain_round_trip(d8):
|
|
42
|
+
written = d8.assert_fact(
|
|
43
|
+
"repo:myproj", "database", "postgres", authority="high", source="user:alice"
|
|
44
|
+
)
|
|
45
|
+
assert written["schema_version"] == SCHEMA_VERSION
|
|
46
|
+
assert written["status"] == "accepted"
|
|
47
|
+
assert written["accepted"] is True
|
|
48
|
+
|
|
49
|
+
fact = d8.explain("repo:myproj", "database")
|
|
50
|
+
assert fact["status"] == "ok"
|
|
51
|
+
assert fact["value"]["text"] == "postgres"
|
|
52
|
+
assert fact["authority"] == "high"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_firewall_rejection_carries_the_code(d8):
|
|
56
|
+
# An unregistered predicate exercises pure arbitration: low cannot displace high.
|
|
57
|
+
d8.assert_fact("person:alice", "favorite_drink", "tea", authority="high", source="user:alice")
|
|
58
|
+
with pytest.raises(Dent8Rejected) as exc:
|
|
59
|
+
d8.supersede("person:alice", "favorite_drink", "coffee", authority="low", source="note:old")
|
|
60
|
+
assert exc.value.status == "rejected"
|
|
61
|
+
assert exc.value.code == "insufficient-authority"
|
|
62
|
+
assert exc.value.payload["schema_version"] == SCHEMA_VERSION
|
|
63
|
+
|
|
64
|
+
# A registered predicate (`database` has a floor in the coding-agent registry) is
|
|
65
|
+
# refused by the policy gate first, with its own code.
|
|
66
|
+
d8.assert_fact("repo:myproj", "database", "postgres", authority="high", source="user:alice")
|
|
67
|
+
with pytest.raises(Dent8Rejected) as floor:
|
|
68
|
+
d8.supersede("repo:myproj", "database", "mysql", authority="low", source="web:scrape")
|
|
69
|
+
assert floor.value.code == "below-authority-floor"
|
|
70
|
+
|
|
71
|
+
# The incumbents survived both challenges.
|
|
72
|
+
assert d8.explain("person:alice", "favorite_drink")["value"]["text"] == "tea"
|
|
73
|
+
assert d8.explain("repo:myproj", "database")["value"]["text"] == "postgres"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_invalid_input_is_invalid_not_rejected(d8):
|
|
77
|
+
with pytest.raises(Dent8Invalid):
|
|
78
|
+
d8.assert_fact("no-colon", "p", "v", authority="high", source="user:alice")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_derive_and_taint_flow(d8):
|
|
82
|
+
d8.assert_fact("repo:myproj", "database", "postgres", authority="high", source="user:alice")
|
|
83
|
+
derived = d8.derive(
|
|
84
|
+
"service:api",
|
|
85
|
+
"datastore",
|
|
86
|
+
"postgres",
|
|
87
|
+
basis=("repo:myproj", "database"),
|
|
88
|
+
authority="high",
|
|
89
|
+
source="user:alice",
|
|
90
|
+
)
|
|
91
|
+
assert derived["status"] == "accepted"
|
|
92
|
+
|
|
93
|
+
d8.retract("repo:myproj", "database", authority="high", source="user:alice")
|
|
94
|
+
report = d8.verify()
|
|
95
|
+
# The basis retraction taints the derivative — a finding, not an exception.
|
|
96
|
+
assert report["status"] == "integrity_issues"
|
|
97
|
+
assert report["ok"] is False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_reads_and_time_travel(d8):
|
|
101
|
+
d8.assert_fact(
|
|
102
|
+
"repo:myproj",
|
|
103
|
+
"database",
|
|
104
|
+
"postgres",
|
|
105
|
+
authority="high",
|
|
106
|
+
source="user:alice",
|
|
107
|
+
valid_to="2036-01-01",
|
|
108
|
+
)
|
|
109
|
+
listing = d8.facts()
|
|
110
|
+
assert listing["count"] == 1
|
|
111
|
+
|
|
112
|
+
history = d8.replay("repo:myproj", "database")
|
|
113
|
+
assert history["events"][0]["kind"] == "fact.asserted"
|
|
114
|
+
|
|
115
|
+
# Human time grammar flows through: a week ago the fact did not exist.
|
|
116
|
+
with pytest.raises(Dent8Rejected):
|
|
117
|
+
d8.explain("repo:myproj", "database", as_of="-7d")
|
|
118
|
+
|
|
119
|
+
clean = d8.conflicts()
|
|
120
|
+
assert clean["status"] == "ok"
|
|
121
|
+
d8.contradict("repo:myproj", "database", "mysql", authority="high", source="user:bob")
|
|
122
|
+
contested = d8.conflicts()
|
|
123
|
+
assert contested["status"] == "contested"
|
|
124
|
+
assert contested["count"] == 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_missing_binary_reports_clearly(tmp_path):
|
|
128
|
+
ghost = Dent8(binary=str(tmp_path / "definitely-not-dent8"))
|
|
129
|
+
with pytest.raises((FileNotFoundError, OSError)):
|
|
130
|
+
ghost.facts()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_binary_contract_matches_sdk_version():
|
|
134
|
+
version = subprocess.run(
|
|
135
|
+
[BINARY, "--version"], capture_output=True, text=True, check=True
|
|
136
|
+
).stdout.strip()
|
|
137
|
+
assert version.startswith("dent8 ")
|