cuff-cli 0.1.0__py3-none-any.whl
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.
- cuff/__init__.py +4 -0
- cuff/__main__.py +4 -0
- cuff/cli.py +230 -0
- cuff/errors.py +19 -0
- cuff/gate.py +206 -0
- cuff/git.py +113 -0
- cuff/ledger.py +640 -0
- cuff/subject.py +180 -0
- cuff/workspace.py +204 -0
- cuff_cli-0.1.0.dist-info/METADATA +162 -0
- cuff_cli-0.1.0.dist-info/RECORD +15 -0
- cuff_cli-0.1.0.dist-info/WHEEL +5 -0
- cuff_cli-0.1.0.dist-info/entry_points.txt +2 -0
- cuff_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- cuff_cli-0.1.0.dist-info/top_level.txt +1 -0
cuff/subject.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Closed subject identities and deterministic filesystem manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .errors import CuffError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SUBJECT_FIELDS = {"kind", "ref", "digest"}
|
|
17
|
+
SUBJECT_KIND_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,119}$")
|
|
18
|
+
SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
19
|
+
FILESYSTEM_KINDS = {"file", "tree"}
|
|
20
|
+
MAX_REF_BYTES = 2048
|
|
21
|
+
MAX_FILES = 10_000
|
|
22
|
+
MAX_BYTES = 256 * 1024 * 1024
|
|
23
|
+
MAX_DEPTH = 64
|
|
24
|
+
MAX_NAME_BYTES = 255
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def declared_subject(kind: str, ref: str, digest: str) -> dict[str, str]:
|
|
28
|
+
subject = {"kind": kind, "ref": ref, "digest": digest}
|
|
29
|
+
validate_subject(subject)
|
|
30
|
+
if kind in FILESYSTEM_KINDS:
|
|
31
|
+
raise CuffError(
|
|
32
|
+
"CUFF_SUBJECT_INVALID",
|
|
33
|
+
"The file and tree subject kinds are reserved for --subject-path",
|
|
34
|
+
)
|
|
35
|
+
return subject
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_subject(value: Any) -> dict[str, str]:
|
|
39
|
+
if not isinstance(value, dict) or set(value) != SUBJECT_FIELDS:
|
|
40
|
+
raise CuffError("CUFF_SUBJECT_INVALID", "Subject fields are invalid")
|
|
41
|
+
kind = value.get("kind")
|
|
42
|
+
ref = value.get("ref")
|
|
43
|
+
digest = value.get("digest")
|
|
44
|
+
if not isinstance(kind, str) or SUBJECT_KIND_RE.fullmatch(kind) is None:
|
|
45
|
+
raise CuffError("CUFF_SUBJECT_INVALID", "Subject kind must be a bounded lowercase token")
|
|
46
|
+
if (
|
|
47
|
+
not isinstance(ref, str)
|
|
48
|
+
or not ref
|
|
49
|
+
or len(ref.encode("utf-8")) > MAX_REF_BYTES
|
|
50
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in ref)
|
|
51
|
+
):
|
|
52
|
+
raise CuffError("CUFF_SUBJECT_INVALID", "Subject ref must be bounded text without controls")
|
|
53
|
+
if not isinstance(digest, str) or SHA256_RE.fullmatch(digest) is None:
|
|
54
|
+
raise CuffError("CUFF_SUBJECT_INVALID", "Subject digest must be lowercase SHA-256")
|
|
55
|
+
return {"kind": kind, "ref": ref, "digest": digest}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def filesystem_subject(root: Path, path: Path) -> dict[str, str]:
|
|
59
|
+
workspace = root.resolve()
|
|
60
|
+
target, reference = _bounded_target(workspace, path)
|
|
61
|
+
target_stat = target.stat(follow_symlinks=False)
|
|
62
|
+
if stat.S_ISREG(target_stat.st_mode):
|
|
63
|
+
entries = [_file_entry(target, reference, target_stat)]
|
|
64
|
+
kind = "file"
|
|
65
|
+
elif stat.S_ISDIR(target_stat.st_mode):
|
|
66
|
+
entries = _tree_entries(workspace, target)
|
|
67
|
+
kind = "tree"
|
|
68
|
+
else:
|
|
69
|
+
raise CuffError(
|
|
70
|
+
"CUFF_SUBJECT_PATH_INVALID",
|
|
71
|
+
"Filesystem subjects must be regular files or directories",
|
|
72
|
+
{"path": str(path)},
|
|
73
|
+
)
|
|
74
|
+
total_bytes = sum(entry["bytes"] for entry in entries)
|
|
75
|
+
if len(entries) > MAX_FILES or total_bytes > MAX_BYTES:
|
|
76
|
+
raise CuffError(
|
|
77
|
+
"CUFF_SUBJECT_BOUNDS",
|
|
78
|
+
"Filesystem subject exceeds the file or byte bound",
|
|
79
|
+
{"files": len(entries), "bytes": total_bytes},
|
|
80
|
+
)
|
|
81
|
+
manifest = {"format": "cuff-manifest-1", "entries": entries}
|
|
82
|
+
encoded = json.dumps(
|
|
83
|
+
manifest,
|
|
84
|
+
sort_keys=True,
|
|
85
|
+
separators=(",", ":"),
|
|
86
|
+
ensure_ascii=False,
|
|
87
|
+
allow_nan=False,
|
|
88
|
+
).encode()
|
|
89
|
+
return {"kind": kind, "ref": reference, "digest": "sha256:" + hashlib.sha256(encoded).hexdigest()}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def current_subject(root: Path, subject: dict[str, str]) -> dict[str, str]:
|
|
93
|
+
validated = validate_subject(subject)
|
|
94
|
+
if validated["kind"] not in FILESYSTEM_KINDS:
|
|
95
|
+
return validated
|
|
96
|
+
current = filesystem_subject(root, Path(validated["ref"]))
|
|
97
|
+
if current["kind"] != validated["kind"]:
|
|
98
|
+
raise CuffError("CUFF_SUBJECT_CHANGED", "Filesystem subject kind changed")
|
|
99
|
+
return current
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _bounded_target(workspace: Path, path: Path) -> tuple[Path, str]:
|
|
103
|
+
raw = path.expanduser()
|
|
104
|
+
candidate = raw if raw.is_absolute() else workspace / raw
|
|
105
|
+
lexical = Path(os.path.abspath(candidate))
|
|
106
|
+
try:
|
|
107
|
+
relative = lexical.relative_to(workspace)
|
|
108
|
+
except ValueError as exc:
|
|
109
|
+
raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Subject path escapes the workspace") from exc
|
|
110
|
+
if relative.parts[:1] == (".cuff",):
|
|
111
|
+
raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Cuff controller state cannot be a subject")
|
|
112
|
+
current = workspace
|
|
113
|
+
for part in relative.parts:
|
|
114
|
+
if len(os.fsencode(part)) > MAX_NAME_BYTES:
|
|
115
|
+
raise CuffError("CUFF_SUBJECT_BOUNDS", "Filesystem subject name exceeds the bound")
|
|
116
|
+
current /= part
|
|
117
|
+
if current.is_symlink():
|
|
118
|
+
raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Filesystem subjects must not contain symlinks")
|
|
119
|
+
if not lexical.exists():
|
|
120
|
+
raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Filesystem subject does not exist")
|
|
121
|
+
resolved = lexical.resolve()
|
|
122
|
+
try:
|
|
123
|
+
resolved.relative_to(workspace)
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Subject path escapes the workspace") from exc
|
|
126
|
+
reference = relative.as_posix() if relative.parts else "."
|
|
127
|
+
return resolved, reference
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _tree_entries(workspace: Path, target: Path) -> list[dict[str, Any]]:
|
|
131
|
+
entries: list[dict[str, Any]] = []
|
|
132
|
+
total_bytes = 0
|
|
133
|
+
for directory, names, filenames in os.walk(target, topdown=True, followlinks=False):
|
|
134
|
+
directory_path = Path(directory)
|
|
135
|
+
relative_directory = directory_path.relative_to(target)
|
|
136
|
+
if len(relative_directory.parts) > MAX_DEPTH:
|
|
137
|
+
raise CuffError("CUFF_SUBJECT_BOUNDS", "Filesystem subject exceeds the depth bound")
|
|
138
|
+
for name in [*names, *filenames]:
|
|
139
|
+
if len(os.fsencode(name)) > MAX_NAME_BYTES:
|
|
140
|
+
raise CuffError("CUFF_SUBJECT_BOUNDS", "Filesystem subject name exceeds the bound")
|
|
141
|
+
|
|
142
|
+
kept_names: list[str] = []
|
|
143
|
+
for name in names:
|
|
144
|
+
candidate = directory_path / name
|
|
145
|
+
workspace_relative = candidate.relative_to(workspace)
|
|
146
|
+
if workspace_relative.parts[:1] == (".cuff",):
|
|
147
|
+
continue
|
|
148
|
+
directory_mode = candidate.stat(follow_symlinks=False).st_mode
|
|
149
|
+
if stat.S_ISLNK(directory_mode):
|
|
150
|
+
raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Filesystem subjects must not contain symlinks")
|
|
151
|
+
if not stat.S_ISDIR(directory_mode):
|
|
152
|
+
raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Filesystem subjects contain a special entry")
|
|
153
|
+
kept_names.append(name)
|
|
154
|
+
names[:] = sorted(kept_names, key=os.fsencode)
|
|
155
|
+
|
|
156
|
+
for name in sorted(filenames, key=os.fsencode):
|
|
157
|
+
candidate = directory_path / name
|
|
158
|
+
metadata = candidate.stat(follow_symlinks=False)
|
|
159
|
+
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
|
160
|
+
raise CuffError("CUFF_SUBJECT_PATH_INVALID", "Filesystem subjects contain a special entry")
|
|
161
|
+
relative = candidate.relative_to(workspace).as_posix()
|
|
162
|
+
entries.append(_file_entry(candidate, relative, metadata))
|
|
163
|
+
total_bytes += metadata.st_size
|
|
164
|
+
if len(entries) > MAX_FILES or total_bytes > MAX_BYTES:
|
|
165
|
+
raise CuffError("CUFF_SUBJECT_BOUNDS", "Filesystem subject exceeds the file or byte bound")
|
|
166
|
+
entries.sort(key=lambda entry: os.fsencode(entry["path"]))
|
|
167
|
+
return entries
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _file_entry(path: Path, reference: str, metadata: os.stat_result) -> dict[str, Any]:
|
|
171
|
+
digest = hashlib.sha256()
|
|
172
|
+
with path.open("rb") as handle:
|
|
173
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
174
|
+
digest.update(chunk)
|
|
175
|
+
return {
|
|
176
|
+
"path": reference,
|
|
177
|
+
"mode": stat.S_IMODE(metadata.st_mode),
|
|
178
|
+
"bytes": metadata.st_size,
|
|
179
|
+
"digest": "sha256:" + digest.hexdigest(),
|
|
180
|
+
}
|
cuff/workspace.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Safe selection and initialization of one Git-root Cuff workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import git
|
|
12
|
+
from .errors import CuffError
|
|
13
|
+
from .ledger import RECORDS_DIR, init as init_records
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
MAX_PARENT_WALK = 64
|
|
17
|
+
PROJECT_PATH = Path(".cuff/project.json")
|
|
18
|
+
PROJECT_MARKER = {"schema": 1}
|
|
19
|
+
PROJECT_MARKER_BYTES = b'{"schema":1}\n'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def initialize_workspace(
|
|
23
|
+
explicit: Path | None = None,
|
|
24
|
+
*,
|
|
25
|
+
cwd: Path | None = None,
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
"""Initialize only the caller-selected directory or current directory."""
|
|
28
|
+
start = (cwd or Path.cwd()).resolve()
|
|
29
|
+
root = _existing_directory(explicit or start, start)
|
|
30
|
+
_require_git_root(root)
|
|
31
|
+
state = root / ".cuff"
|
|
32
|
+
marker = root / PROJECT_PATH
|
|
33
|
+
_require_safe_state_paths(state, marker)
|
|
34
|
+
_require_safe_records_path(root / RECORDS_DIR)
|
|
35
|
+
|
|
36
|
+
existed = marker.exists()
|
|
37
|
+
if existed:
|
|
38
|
+
_read_marker(marker)
|
|
39
|
+
else:
|
|
40
|
+
state.mkdir(mode=0o755, exist_ok=True)
|
|
41
|
+
init_records(root)
|
|
42
|
+
if not existed:
|
|
43
|
+
_create_marker(marker)
|
|
44
|
+
return {
|
|
45
|
+
"ok": True,
|
|
46
|
+
"status": "already_initialized" if existed else "initialized",
|
|
47
|
+
"workspace": str(root),
|
|
48
|
+
"marker": dict(PROJECT_MARKER),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_workspace(
|
|
53
|
+
explicit: Path | None = None,
|
|
54
|
+
*,
|
|
55
|
+
cwd: Path | None = None,
|
|
56
|
+
) -> Path:
|
|
57
|
+
"""Find and validate the explicit or nearest parent Cuff workspace."""
|
|
58
|
+
start = (cwd or Path.cwd()).resolve()
|
|
59
|
+
if explicit is not None:
|
|
60
|
+
root = _existing_directory(explicit, start)
|
|
61
|
+
_validate_project(root)
|
|
62
|
+
return root
|
|
63
|
+
|
|
64
|
+
current = start
|
|
65
|
+
for _ in range(MAX_PARENT_WALK):
|
|
66
|
+
state = current / ".cuff"
|
|
67
|
+
marker = current / PROJECT_PATH
|
|
68
|
+
if state.is_symlink() or (state.exists() and not state.is_dir()):
|
|
69
|
+
raise CuffError(
|
|
70
|
+
"CUFF_PATH_INVALID",
|
|
71
|
+
"Cuff state path in workspace discovery is unsafe",
|
|
72
|
+
{"path": str(state)},
|
|
73
|
+
)
|
|
74
|
+
if marker.exists() or marker.is_symlink():
|
|
75
|
+
_validate_project(current)
|
|
76
|
+
return current
|
|
77
|
+
parent = current.parent
|
|
78
|
+
if parent == current:
|
|
79
|
+
break
|
|
80
|
+
current = parent
|
|
81
|
+
raise _not_initialized(start)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _validate_project(root: Path) -> None:
|
|
85
|
+
state = root / ".cuff"
|
|
86
|
+
marker = root / PROJECT_PATH
|
|
87
|
+
if not marker.exists() and not marker.is_symlink():
|
|
88
|
+
raise _not_initialized(root)
|
|
89
|
+
_require_safe_state_paths(state, marker)
|
|
90
|
+
_read_marker(marker)
|
|
91
|
+
_require_git_root(root)
|
|
92
|
+
records = root / RECORDS_DIR
|
|
93
|
+
_require_safe_records_path(records)
|
|
94
|
+
if not records.is_dir():
|
|
95
|
+
raise CuffError(
|
|
96
|
+
"CUFF_PROJECT_INVALID",
|
|
97
|
+
"Cuff records directory is missing or unsafe; rerun cuff init",
|
|
98
|
+
{"path": str(records)},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _require_git_root(root: Path) -> None:
|
|
103
|
+
repository = git.repo_root(root)
|
|
104
|
+
if repository != root.resolve():
|
|
105
|
+
raise CuffError(
|
|
106
|
+
"CUFF_WORKSPACE_NOT_ROOT",
|
|
107
|
+
"Cuff workspace must be the Git worktree root",
|
|
108
|
+
{"workspace": str(root.resolve()), "repository": str(repository)},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _existing_directory(path: Path, start: Path) -> Path:
|
|
113
|
+
candidate = path.expanduser()
|
|
114
|
+
if not candidate.is_absolute():
|
|
115
|
+
candidate = start / candidate
|
|
116
|
+
if candidate.is_symlink() or not candidate.is_dir():
|
|
117
|
+
raise CuffError(
|
|
118
|
+
"CUFF_WORKSPACE_INVALID",
|
|
119
|
+
"Workspace must be one existing non-symlink directory",
|
|
120
|
+
{"path": str(candidate)},
|
|
121
|
+
)
|
|
122
|
+
return candidate.resolve()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _require_safe_state_paths(state: Path, marker: Path) -> None:
|
|
126
|
+
if state.is_symlink() or (state.exists() and not state.is_dir()):
|
|
127
|
+
raise CuffError(
|
|
128
|
+
"CUFF_PATH_INVALID",
|
|
129
|
+
"Cuff state directory must be a non-symlink directory",
|
|
130
|
+
{"path": str(state)},
|
|
131
|
+
)
|
|
132
|
+
if marker.is_symlink() or (marker.exists() and not marker.is_file()):
|
|
133
|
+
raise CuffError(
|
|
134
|
+
"CUFF_PATH_INVALID",
|
|
135
|
+
"Cuff project marker must be a regular non-symlink file",
|
|
136
|
+
{"path": str(marker)},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _require_safe_records_path(records: Path) -> None:
|
|
141
|
+
if records.is_symlink() or (records.exists() and not records.is_dir()):
|
|
142
|
+
raise CuffError(
|
|
143
|
+
"CUFF_PATH_INVALID",
|
|
144
|
+
"Cuff records path must be a non-symlink directory",
|
|
145
|
+
{"path": str(records)},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _read_marker(path: Path) -> dict[str, int]:
|
|
150
|
+
try:
|
|
151
|
+
content = path.read_bytes()
|
|
152
|
+
data = json.loads(content, object_pairs_hook=_no_duplicate_keys)
|
|
153
|
+
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
|
|
154
|
+
raise CuffError(
|
|
155
|
+
"CUFF_PROJECT_INCOMPATIBLE",
|
|
156
|
+
"Cuff project marker is incompatible; archive or remove only the marker and rerun cuff init",
|
|
157
|
+
{"path": str(path)},
|
|
158
|
+
) from exc
|
|
159
|
+
if data != PROJECT_MARKER or content != PROJECT_MARKER_BYTES:
|
|
160
|
+
raise CuffError(
|
|
161
|
+
"CUFF_PROJECT_INCOMPATIBLE",
|
|
162
|
+
"Cuff project marker is incompatible; archive or remove only the marker and rerun cuff init",
|
|
163
|
+
{"path": str(path)},
|
|
164
|
+
)
|
|
165
|
+
return data
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _create_marker(path: Path) -> None:
|
|
169
|
+
temporary: Path | None = None
|
|
170
|
+
try:
|
|
171
|
+
with tempfile.NamedTemporaryFile("wb", dir=path.parent, prefix=".project.", delete=False) as handle:
|
|
172
|
+
temporary = Path(handle.name)
|
|
173
|
+
handle.write(PROJECT_MARKER_BYTES)
|
|
174
|
+
handle.flush()
|
|
175
|
+
os.fsync(handle.fileno())
|
|
176
|
+
try:
|
|
177
|
+
os.link(temporary, path)
|
|
178
|
+
except FileExistsError:
|
|
179
|
+
_read_marker(path)
|
|
180
|
+
descriptor = os.open(path.parent, os.O_RDONLY)
|
|
181
|
+
try:
|
|
182
|
+
os.fsync(descriptor)
|
|
183
|
+
finally:
|
|
184
|
+
os.close(descriptor)
|
|
185
|
+
finally:
|
|
186
|
+
if temporary is not None and temporary.exists():
|
|
187
|
+
temporary.unlink()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _no_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
191
|
+
data: dict[str, Any] = {}
|
|
192
|
+
for key, value in pairs:
|
|
193
|
+
if key in data:
|
|
194
|
+
raise ValueError(f"duplicate JSON key: {key}")
|
|
195
|
+
data[key] = value
|
|
196
|
+
return data
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _not_initialized(path: Path) -> CuffError:
|
|
200
|
+
return CuffError(
|
|
201
|
+
"CUFF_PROJECT_NOT_INITIALIZED",
|
|
202
|
+
"No Cuff project was found; run cuff init or pass --workspace",
|
|
203
|
+
{"path": str(path)},
|
|
204
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cuff-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cuff subject-bound claim, evidence, and verification gate.
|
|
5
|
+
Author: Fab7
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<img src="docs/assets/banner.svg" alt="Cuff check flow: bind the subject, observe the verifier, require fresh evidence" width="100%" />
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
# Cuff
|
|
17
|
+
|
|
18
|
+
**Make claims checkable. Reject evidence when stale.**
|
|
19
|
+
|
|
20
|
+
Cuff ties one completion claim to one exact subject, runs the verifier you
|
|
21
|
+
choose, and checks whether the latest passing evidence still matches the
|
|
22
|
+
current Git state.
|
|
23
|
+
|
|
24
|
+
It turns a completion statement into a durable, checkable record without
|
|
25
|
+
deciding what should prove the work or what action should follow.
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Python 3.11 or newer;
|
|
30
|
+
- `uv` on `PATH` (`0.11.32` is the tested recommendation); and
|
|
31
|
+
- an existing Git worktree. Its root is the only valid Cuff workspace.
|
|
32
|
+
|
|
33
|
+
Git is mandatory. Cuff never initializes a repository, selects another
|
|
34
|
+
worktree, or stages, commits, fetches, pushes, releases, or deploys anything.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Install a released version as a standard uv-managed tool:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv tool install cuff-cli==0.1.0
|
|
42
|
+
cuff --version
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For local development, install the checkout explicitly:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv tool install --editable .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Cuff has no runtime dependencies. It is distributed as a standard wheel and
|
|
52
|
+
source distribution; it contains no bundled Python or native executable.
|
|
53
|
+
|
|
54
|
+
## Five-command quickstart
|
|
55
|
+
|
|
56
|
+
Run initialization at the exact Git worktree root:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
cuff init --json
|
|
60
|
+
git add .cuff/project.json
|
|
61
|
+
git commit -m "Initialize Cuff"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The marker is exactly `{"schema":1}` and records live under
|
|
65
|
+
`.cuff/records/`. An incompatible marker is never rewritten or migrated.
|
|
66
|
+
|
|
67
|
+
The preferred path atomically appends a claim and its observed evidence:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cuff seal \
|
|
71
|
+
--work-item task-1 \
|
|
72
|
+
--summary "Implementation complete" \
|
|
73
|
+
--subject-path src \
|
|
74
|
+
--json \
|
|
75
|
+
-- python -m pytest
|
|
76
|
+
|
|
77
|
+
cuff check --work-item task-1 --json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The split path is available when the claim must exist before verification:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
cuff claim \
|
|
84
|
+
--work-item task-1 \
|
|
85
|
+
--summary "Implementation complete" \
|
|
86
|
+
--subject-path src \
|
|
87
|
+
--json
|
|
88
|
+
|
|
89
|
+
cuff verify \
|
|
90
|
+
--work-item task-1 \
|
|
91
|
+
--claim rec_REPLACE_ME \
|
|
92
|
+
--json \
|
|
93
|
+
-- python -m pytest
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The public surface is exactly:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
cuff init
|
|
100
|
+
cuff claim
|
|
101
|
+
cuff verify
|
|
102
|
+
cuff seal
|
|
103
|
+
cuff check
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Every claim, verification, seal, and check names its work item explicitly.
|
|
107
|
+
Declared subjects use the complete `{kind, ref, digest}` identity; file and
|
|
108
|
+
tree subjects use `--subject-path` and a Cuff-computed manifest digest.
|
|
109
|
+
|
|
110
|
+
## Proof boundary
|
|
111
|
+
|
|
112
|
+
- Claims and evidence are closed generation-1 JSONL records.
|
|
113
|
+
- Every evidence record contains the `HEAD` commit observed before execution.
|
|
114
|
+
- Verifier argv is executed literally without a shell.
|
|
115
|
+
- Non-ledger dirtiness before or after verification records no evidence.
|
|
116
|
+
- `seal` appends its linked pair in one locked atomic replacement.
|
|
117
|
+
- `check` enforces subject freshness, commit ancestry, changed paths,
|
|
118
|
+
non-ledger cleanliness, and append-only ledger changes.
|
|
119
|
+
|
|
120
|
+
Cuff treats verifier argv as opaque. It does not select the command, import an
|
|
121
|
+
extension, interpret domain output, or grant merge, release, deployment,
|
|
122
|
+
spend, or residual-risk authority.
|
|
123
|
+
|
|
124
|
+
## Static host integrations
|
|
125
|
+
|
|
126
|
+
Small native assets live in [`plugins/claude/cuff`](plugins/claude/cuff) and
|
|
127
|
+
[`plugins/codex/cuff`](plugins/codex/cuff). The corresponding host plugin
|
|
128
|
+
manager owns their installation and removal. These assets require only the
|
|
129
|
+
uv-managed `cuff` executable on `PATH`; Cuff itself does not install plugins.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Codex
|
|
133
|
+
codex plugin marketplace add fab7hq/cuff --ref v0.1.0
|
|
134
|
+
codex plugin add cuff@fab7hq
|
|
135
|
+
|
|
136
|
+
# Claude Code
|
|
137
|
+
claude plugin marketplace add fab7hq/cuff@v0.1.0
|
|
138
|
+
claude plugin install cuff@fab7hq --scope user
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
See [RUNBOOK.md](RUNBOOK.md) for operations, [the architecture overview](docs/architecture/overview.md)
|
|
142
|
+
for ownership, and [the ledger contract](docs/architecture/ledger.md) for the
|
|
143
|
+
record and gate invariants.
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
uv sync --locked
|
|
149
|
+
uv run --locked python -m pytest
|
|
150
|
+
uv run --locked python -m compileall -q core/cuff
|
|
151
|
+
uv build
|
|
152
|
+
git diff --check
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Community and support
|
|
156
|
+
|
|
157
|
+
- Use [Cuff Discussions](https://github.com/fab7hq/cuff/discussions) for usage questions and design proposals.
|
|
158
|
+
- Report reproducible defects through [GitHub Issues](https://github.com/fab7hq/cuff/issues/new/choose).
|
|
159
|
+
- Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a change.
|
|
160
|
+
- Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
|
|
161
|
+
|
|
162
|
+
Cuff is licensed under the [Apache License 2.0](LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
cuff/__init__.py,sha256=j7VeQDzYOCfi6j72wCBo99g9xUmJxyVZRsyBqFvd-iM,100
|
|
2
|
+
cuff/__main__.py,sha256=8F2QDwc1Aevh_MqyENACDK1freyN9sNexmi7IKlbfGU,53
|
|
3
|
+
cuff/cli.py,sha256=wM3jJQi5c_ozBrXDGggjjohNnSkCwES9W3J5d7jesAg,8514
|
|
4
|
+
cuff/errors.py,sha256=Hl_g101k6efJNKIwVnAbG0_7kstCT3ZU_ix1CtEp85E,489
|
|
5
|
+
cuff/gate.py,sha256=t4LUXx1ON4hf6u1eftstyJXRtqNXhtA0MsOaootSUp0,6493
|
|
6
|
+
cuff/git.py,sha256=bM-x67Q_vD29dENvm8bgl5dFWDFtUig1RRYN8fl8B68,4029
|
|
7
|
+
cuff/ledger.py,sha256=Us73NcbxDSDFOXmLRHBDamt9x7OE1PoUL_FaujgOn8I,22763
|
|
8
|
+
cuff/subject.py,sha256=0S2e0dz1UOM8WQoBO21WjAW3XNJS8zyRUn8t-UEdqNY,7576
|
|
9
|
+
cuff/workspace.py,sha256=9ANo_L01GFzD3GURlWDDXS_q5iST2dFfzIWAEazT6NI,6465
|
|
10
|
+
cuff_cli-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
11
|
+
cuff_cli-0.1.0.dist-info/METADATA,sha256=OXbJ992OkxEGR0nw2kC_2I9734jGTRpN6ElIS1GY3Do,4725
|
|
12
|
+
cuff_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
cuff_cli-0.1.0.dist-info/entry_points.txt,sha256=xDe9VI-R88K9P5llkgU6MOH44Ui0_7SeYr7mLQkS1_U,39
|
|
14
|
+
cuff_cli-0.1.0.dist-info/top_level.txt,sha256=zOwA6w7ZmmamL--uv26Xgrfp9rFSbBIt-69kDEPjK-k,5
|
|
15
|
+
cuff_cli-0.1.0.dist-info/RECORD,,
|