tawn 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.
- tawn/__init__.py +3 -0
- tawn/_webserver.py +66 -0
- tawn/branding.py +82 -0
- tawn/capability/__init__.py +1 -0
- tawn/capability/audit.py +87 -0
- tawn/capability/fs.py +67 -0
- tawn/capability/grants.py +53 -0
- tawn/capability/integrity.py +38 -0
- tawn/cli.py +2183 -0
- tawn/compiler/__init__.py +0 -0
- tawn/compiler/classifier.py +79 -0
- tawn/compiler/compiler.py +409 -0
- tawn/compiler/conflicts.py +49 -0
- tawn/compiler/delta.py +179 -0
- tawn/compiler/embedder.py +208 -0
- tawn/compiler/entities.py +126 -0
- tawn/compiler/parser.py +237 -0
- tawn/compiler/wiki.py +197 -0
- tawn/config.py +26 -0
- tawn/db.py +107 -0
- tawn/dbsetup.py +60 -0
- tawn/domains/__init__.py +1 -0
- tawn/domains/academic/__init__.py +22 -0
- tawn/domains/base.py +33 -0
- tawn/domains/creation.py +104 -0
- tawn/domains/hobby/__init__.py +16 -0
- tawn/domains/records.py +112 -0
- tawn/domains/registry.py +116 -0
- tawn/domains/research/__init__.py +21 -0
- tawn/domains/stub.py +32 -0
- tawn/domains/wealth/__init__.py +11 -0
- tawn/domains/wealth/api.py +51 -0
- tawn/domains/wealth/cli.py +105 -0
- tawn/domains/wealth/dashboard.py +62 -0
- tawn/domains/wealth/holdings.py +76 -0
- tawn/domains/wealth/prices.py +101 -0
- tawn/domains/wealth/schedule.py +63 -0
- tawn/domains/wealth/snapshot.py +118 -0
- tawn/domains/work/__init__.py +17 -0
- tawn/federation/__init__.py +0 -0
- tawn/federation/adapters/__init__.py +0 -0
- tawn/federation/adapters/base.py +35 -0
- tawn/federation/adapters/chatgpt.py +59 -0
- tawn/federation/adapters/claude_ai.py +43 -0
- tawn/federation/adapters/claude_code.py +84 -0
- tawn/federation/adapters/codex.py +71 -0
- tawn/federation/adapters/gemini.py +46 -0
- tawn/federation/adapters/gemini_cli.py +109 -0
- tawn/federation/adapters/generic.py +49 -0
- tawn/federation/config.py +41 -0
- tawn/federation/discovery.py +122 -0
- tawn/federation/dispatcher.py +43 -0
- tawn/federation/exporter.py +112 -0
- tawn/federation/merge.py +172 -0
- tawn/federation/normalizer.py +151 -0
- tawn/federation/schema.py +38 -0
- tawn/federation/systemd.py +128 -0
- tawn/federation/watcher.py +98 -0
- tawn/history.py +80 -0
- tawn/home.py +93 -0
- tawn/ignore.py +183 -0
- tawn/mcp_server.py +75 -0
- tawn/memory/__init__.py +0 -0
- tawn/memory/brief.py +72 -0
- tawn/memory/note.py +62 -0
- tawn/memory/recall.py +175 -0
- tawn/memory/schema.py +154 -0
- tawn/model/__init__.py +1 -0
- tawn/model/breaker.py +57 -0
- tawn/model/catalog.py +106 -0
- tawn/model/directory.py +95 -0
- tawn/model/identity.py +44 -0
- tawn/model/keys.py +52 -0
- tawn/model/ledger.py +93 -0
- tawn/model/personality.py +59 -0
- tawn/model/providers/__init__.py +1 -0
- tawn/model/providers/anthropic.py +127 -0
- tawn/model/providers/gemini.py +138 -0
- tawn/model/providers/ollama.py +149 -0
- tawn/model/providers/openai_compat.py +156 -0
- tawn/model/router.py +346 -0
- tawn/model/types.py +72 -0
- tawn/updater.py +179 -0
- tawn/user_config.py +124 -0
- tawn/web/__init__.py +3 -0
- tawn/web/app.py +290 -0
- tawn/web/dist/assets/abnfDiagram-VRR7QNED-C5F_AKgo.js +1 -0
- tawn/web/dist/assets/arc-DfuEWumi.js +1 -0
- tawn/web/dist/assets/architectureDiagram-ZJ3FMSHR-CMdbMQ-v.js +36 -0
- tawn/web/dist/assets/blockDiagram-677ZJIJ3-BvWKvlkz.js +132 -0
- tawn/web/dist/assets/c4Diagram-LMCZKHZV-cmtcJnEh.js +10 -0
- tawn/web/dist/assets/channel-1wBTkNV8.js +1 -0
- tawn/web/dist/assets/chunk-2Q5K7J3B-CVkZvg6J.js +1 -0
- tawn/web/dist/assets/chunk-32BRIVSS-BJnUTK_p.js +1 -0
- tawn/web/dist/assets/chunk-5VM5RSS4-BgCFFy0k.js +15 -0
- tawn/web/dist/assets/chunk-EX3LRPZG-D3opRKGp.js +231 -0
- tawn/web/dist/assets/chunk-JWPE2WC7-BQBJybxG.js +1 -0
- tawn/web/dist/assets/chunk-MOJQB5TN-DPCuIYGK.js +88 -0
- tawn/web/dist/assets/chunk-RYQCIY6F-BGrEIzMk.js +1 -0
- tawn/web/dist/assets/chunk-V7JOEXUC-16qlVcj5.js +206 -0
- tawn/web/dist/assets/chunk-VR4S4FIN-Dri-A7dn.js +1 -0
- tawn/web/dist/assets/chunk-XXDRQBXY-BZJRsKXY.js +1 -0
- tawn/web/dist/assets/classDiagram-OUVF2IWQ-CidNYYIR.js +1 -0
- tawn/web/dist/assets/classDiagram-v2-EOCWNBFH-CidNYYIR.js +1 -0
- tawn/web/dist/assets/cose-bilkent-JH36ORCC-B7ClDWJL.js +1 -0
- tawn/web/dist/assets/cynefin-VYW2F7L2-DdT7ZLm1.js +178 -0
- tawn/web/dist/assets/cynefinDiagram-TSTJHNR4-D3XVPeTI.js +62 -0
- tawn/web/dist/assets/cytoscape.esm-CUqq0XTU.js +331 -0
- tawn/web/dist/assets/dagre-VKFMJZFB-C7hI6zFs.js +4 -0
- tawn/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- tawn/web/dist/assets/diagram-FQU43EPY-BwkzyqxA.js +3 -0
- tawn/web/dist/assets/diagram-G47NLZAW-BNQLclaM.js +24 -0
- tawn/web/dist/assets/diagram-NH7WQ7WH-C89oedn8.js +24 -0
- tawn/web/dist/assets/diagram-OA4YK3LP-CM_ra1CZ.js +30 -0
- tawn/web/dist/assets/diagram-WEI45ONY-CIyDX7LQ.js +41 -0
- tawn/web/dist/assets/ebnfDiagram-CCIWWBDH-CStWDurL.js +1 -0
- tawn/web/dist/assets/erDiagram-Q63AITRT-BwAeP1e2.js +85 -0
- tawn/web/dist/assets/flowDiagram-23GEKE2U-CAA-JOsf.js +156 -0
- tawn/web/dist/assets/ganttDiagram-NO4QXBWP-7bajtVpE.js +292 -0
- tawn/web/dist/assets/gitGraphDiagram-IHSO6WYX-qW76mQDo.js +106 -0
- tawn/web/dist/assets/graph-C2PyVmSI.js +1 -0
- tawn/web/dist/assets/index-BaAvK0kS.js +162 -0
- tawn/web/dist/assets/index-CPMCxrJ6.js +3 -0
- tawn/web/dist/assets/index-DMnyYUiC.css +1 -0
- tawn/web/dist/assets/infoDiagram-FWYZ7A6U-BN3TAAMm.js +2 -0
- tawn/web/dist/assets/init-Gi6I4Gst.js +1 -0
- tawn/web/dist/assets/ishikawaDiagram-FXEZZL3T-DIjUQHYT.js +70 -0
- tawn/web/dist/assets/journeyDiagram-5HDEW3XC-Cd_0i_MI.js +139 -0
- tawn/web/dist/assets/kanban-definition-HUTT4EX6-EJykSLKC.js +89 -0
- tawn/web/dist/assets/katex-C5jXJg4s.js +257 -0
- tawn/web/dist/assets/layout-HEHzrzvT.js +1 -0
- tawn/web/dist/assets/linear-DZV_74YI.js +1 -0
- tawn/web/dist/assets/map-BQHxRNoK.js +1 -0
- tawn/web/dist/assets/mermaid.core-Cs_D-MaZ.js +314 -0
- tawn/web/dist/assets/mindmap-definition-LN4V7U3C-Dayioghr.js +96 -0
- tawn/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- tawn/web/dist/assets/pegDiagram-2B236MQR-CEg98Wsy.js +1 -0
- tawn/web/dist/assets/pieDiagram-ENE6RG2P-BWzY0gy2.js +39 -0
- tawn/web/dist/assets/quadrantDiagram-ABIIQ3AL-GoCE2r_5.js +7 -0
- tawn/web/dist/assets/railroadDiagram-RFXS5EU6-BxTtKfWT.js +1 -0
- tawn/web/dist/assets/requirementDiagram-TGXJPOKE-CD0t2ic5.js +84 -0
- tawn/web/dist/assets/sankeyDiagram-HTMAVEWB-Om9xv51_.js +40 -0
- tawn/web/dist/assets/sequenceDiagram-DBY2YBRQ-Ct9pa12Y.js +162 -0
- tawn/web/dist/assets/sizeCapture-X5ZJPWSS-D0lniTrU.js +1 -0
- tawn/web/dist/assets/stateDiagram-2N3HPSRC-C3JQoYro.js +1 -0
- tawn/web/dist/assets/stateDiagram-v2-6OUMAXLB-s_yFnFGS.js +1 -0
- tawn/web/dist/assets/swimlanes-5IMT3BWC-B0FKBUkt.js +2 -0
- tawn/web/dist/assets/swimlanesDiagram-G3AALYLV-aVA3YxQh.js +8 -0
- tawn/web/dist/assets/timeline-definition-FHXFAJF6-MhKDYH6H.js +120 -0
- tawn/web/dist/assets/vennDiagram-L72KCM5P-CqvlQmOm.js +34 -0
- tawn/web/dist/assets/wardleyDiagram-EHGQE667-DdS-6LAr.js +78 -0
- tawn/web/dist/assets/xychartDiagram-FW5EYKEG-DpAi9dnU.js +7 -0
- tawn/web/dist/favicon.svg +10 -0
- tawn/web/dist/index.html +31 -0
- tawn/web/routes/__init__.py +0 -0
- tawn/web/routes/chat.py +231 -0
- tawn/web/routes/domains.py +85 -0
- tawn/web/routes/federation.py +411 -0
- tawn/web/routes/grants.py +118 -0
- tawn/web/routes/history.py +23 -0
- tawn/web/routes/memory.py +276 -0
- tawn/web/routes/profile.py +29 -0
- tawn/web/routes/setup.py +100 -0
- tawn/web/routes/update.py +47 -0
- tawn-0.1.0.dist-info/METADATA +201 -0
- tawn-0.1.0.dist-info/RECORD +170 -0
- tawn-0.1.0.dist-info/WHEEL +5 -0
- tawn-0.1.0.dist-info/entry_points.txt +9 -0
- tawn-0.1.0.dist-info/licenses/LICENSE +21 -0
- tawn-0.1.0.dist-info/top_level.txt +1 -0
tawn/__init__.py
ADDED
tawn/_webserver.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Internal entrypoint for the background web server process.
|
|
2
|
+
|
|
3
|
+
Run as: python -m tawn._webserver <port>
|
|
4
|
+
Not intended for direct user invocation — use `tawn web start/stop`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _start_auto_compiler(engine) -> None:
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from tawn.db import session as db_session
|
|
14
|
+
from tawn.compiler.compiler import run_compile as run_compiler
|
|
15
|
+
from tawn.federation.merge import scan_all_sources, merge_pending
|
|
16
|
+
from tawn.home import tawn_home
|
|
17
|
+
|
|
18
|
+
def _bootstrap_then_loop():
|
|
19
|
+
# Bootstrap: ingest + merge all existing federation files on startup.
|
|
20
|
+
# Runs in this background thread, not before uvicorn.run() — a
|
|
21
|
+
# source directory can hold thousands of files (a size cap on
|
|
22
|
+
# individual files exists, but the walk itself over many entries
|
|
23
|
+
# still takes real time), and the web server must bind its port
|
|
24
|
+
# immediately regardless of how long that one-time scan takes.
|
|
25
|
+
try:
|
|
26
|
+
with db_session(engine) as s:
|
|
27
|
+
n = scan_all_sources(tawn_home(), s)
|
|
28
|
+
if n > 0:
|
|
29
|
+
merge_pending(tawn_home(), s)
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
while True:
|
|
34
|
+
try:
|
|
35
|
+
with db_session(engine) as session:
|
|
36
|
+
run_compiler(tawn_home(), session)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
time.sleep(30 * 60) # 30-minute interval
|
|
40
|
+
|
|
41
|
+
t = threading.Thread(target=_bootstrap_then_loop, name="tawn-auto-compiler", daemon=True)
|
|
42
|
+
t.start()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(port: int = 8787) -> None:
|
|
46
|
+
import uvicorn
|
|
47
|
+
from tawn.db import make_engine
|
|
48
|
+
from tawn.web import create_app
|
|
49
|
+
|
|
50
|
+
from tawn.db import init_db
|
|
51
|
+
from tawn.updater import start_daily_updater
|
|
52
|
+
engine = make_engine(pooled=True)
|
|
53
|
+
init_db(engine)
|
|
54
|
+
start_daily_updater()
|
|
55
|
+
_start_auto_compiler(engine)
|
|
56
|
+
uvicorn.run(
|
|
57
|
+
create_app(engine),
|
|
58
|
+
host="127.0.0.1",
|
|
59
|
+
port=port,
|
|
60
|
+
log_level="warning",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8787
|
|
66
|
+
main(port)
|
tawn/branding.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Branded terminal identity (brand/README.md: Cairn × Sandstone & Lapis).
|
|
2
|
+
|
|
3
|
+
The cairn: three stones, lapis capstone. The wordmark lettering follows
|
|
4
|
+
the drawn monoline mark — rounded, lowercase, final n in lapis.
|
|
5
|
+
Colors are the brand tokens; Rich truecolor degrades gracefully.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from rich.columns import Columns
|
|
9
|
+
from rich.console import Group
|
|
10
|
+
from rich.padding import Padding
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
# brand tokens (brand/tokens.css) — dark-ground values; terminals are
|
|
15
|
+
# usually dark, and both lapis values pass on light too.
|
|
16
|
+
LAPIS = "#4A7BC8"
|
|
17
|
+
BONE = "#EFE9DC"
|
|
18
|
+
MUTED = "#ADA28C"
|
|
19
|
+
|
|
20
|
+
_CAIRN = [
|
|
21
|
+
(" ▄██▄ ", LAPIS),
|
|
22
|
+
(" ▀██▀ ", LAPIS),
|
|
23
|
+
(" ▄████▄ ", BONE),
|
|
24
|
+
(" ▀████▀ ", BONE),
|
|
25
|
+
(" ▄██████▄ ", BONE),
|
|
26
|
+
(" ▀██████▀ ", BONE),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# monoline block lettering — t a w in bone, n in lapis (the letter that
|
|
30
|
+
# makes it *own*), matching the wordmark rule in brand/README.md
|
|
31
|
+
_LETTERS = [
|
|
32
|
+
("▄█▄ ▄▀▀▄ █ █ ", "█▀▀▄"),
|
|
33
|
+
(" █ █▄▄█ █ ▄ █ ", "█ █"),
|
|
34
|
+
(" ▀▄ █ █ ▀▄▀▄▀ ", "█ █"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cairn() -> Text:
|
|
39
|
+
art = Text()
|
|
40
|
+
for i, (line, color) in enumerate(_CAIRN):
|
|
41
|
+
art.append(line, style=color)
|
|
42
|
+
if i < len(_CAIRN) - 1:
|
|
43
|
+
art.append("\n")
|
|
44
|
+
return art
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def wordmark() -> Text:
|
|
48
|
+
art = Text()
|
|
49
|
+
for i, (taw, n) in enumerate(_LETTERS):
|
|
50
|
+
art.append(taw, style=f"bold {BONE}")
|
|
51
|
+
art.append(n, style=f"bold {LAPIS}")
|
|
52
|
+
if i < len(_LETTERS) - 1:
|
|
53
|
+
art.append("\n")
|
|
54
|
+
return art
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def banner(version: str) -> Group:
|
|
58
|
+
lockup = Columns([cairn(), Padding(wordmark(), (1, 0, 0, 2))], padding=(0, 2))
|
|
59
|
+
tag = Text()
|
|
60
|
+
tag.append("the twin you own", style=f"italic {MUTED}")
|
|
61
|
+
tag.append(f" · v{version}", style=MUTED)
|
|
62
|
+
return Group(lockup, Padding(tag, (0, 0, 0, 1)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def status_table(rows: list[tuple[str, bool, str]]) -> Table:
|
|
66
|
+
t = Table(show_header=False, box=None, padding=(0, 1))
|
|
67
|
+
t.add_column(style=MUTED)
|
|
68
|
+
t.add_column()
|
|
69
|
+
t.add_column(style=MUTED)
|
|
70
|
+
for name, ok, detail in rows:
|
|
71
|
+
mark = Text("●", style="green" if ok else "red")
|
|
72
|
+
t.add_row(name, mark, detail)
|
|
73
|
+
return t
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def commands_table(commands: list[tuple[str, str]]) -> Table:
|
|
77
|
+
t = Table(show_header=False, box=None, padding=(0, 1))
|
|
78
|
+
t.add_column(style=f"bold {LAPIS}")
|
|
79
|
+
t.add_column(style=MUTED)
|
|
80
|
+
for cmd, desc in commands:
|
|
81
|
+
t.add_row(cmd, desc)
|
|
82
|
+
return t
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Capability layer: grants, integrity, audit, and the FS chokepoint."""
|
tawn/capability/audit.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Append-only JSONL audit log (spec §10: "audit everything").
|
|
2
|
+
|
|
3
|
+
Immutability contract: every entry includes a sha256 chain hash linking it
|
|
4
|
+
to the previous entry — tampering breaks the chain. File is chmod 600.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import csv
|
|
8
|
+
import hashlib
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuditLog:
|
|
18
|
+
def __init__(self, path: Path):
|
|
19
|
+
self.path = path
|
|
20
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
def _last_hash(self) -> str:
|
|
23
|
+
if not self.path.exists():
|
|
24
|
+
return "genesis"
|
|
25
|
+
lines = [l for l in self.path.read_text().splitlines() if l]
|
|
26
|
+
if not lines:
|
|
27
|
+
return "genesis"
|
|
28
|
+
return json.loads(lines[-1]).get("chain", "genesis")
|
|
29
|
+
|
|
30
|
+
def record(self, op: str, target: str, ok: bool, detail: str = "", actor: str = "system") -> None:
|
|
31
|
+
"""actor: who initiated this — "cli" | "web" | "chat" | "mcp" | "system"
|
|
32
|
+
(background jobs: auto-compiler, scheduled snapshots, federation watcher).
|
|
33
|
+
"""
|
|
34
|
+
prev_hash = self._last_hash()
|
|
35
|
+
entry = {
|
|
36
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
37
|
+
"op": op,
|
|
38
|
+
"target": target,
|
|
39
|
+
"ok": ok,
|
|
40
|
+
"detail": detail,
|
|
41
|
+
"actor": actor,
|
|
42
|
+
}
|
|
43
|
+
# chain hash = sha256 of prev_hash + this entry (without chain field)
|
|
44
|
+
payload = prev_hash + json.dumps(entry, sort_keys=True)
|
|
45
|
+
entry["chain"] = hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
46
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
47
|
+
f.write(json.dumps(entry) + "\n")
|
|
48
|
+
# restrict file to owner-only after every write
|
|
49
|
+
try:
|
|
50
|
+
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def entries(self, limit: int = 0, offset: int = 0) -> list[dict]:
|
|
55
|
+
if not self.path.exists():
|
|
56
|
+
return []
|
|
57
|
+
all_entries = [json.loads(line) for line in self.path.read_text().splitlines() if line]
|
|
58
|
+
if offset:
|
|
59
|
+
all_entries = all_entries[offset:]
|
|
60
|
+
if limit:
|
|
61
|
+
all_entries = all_entries[:limit]
|
|
62
|
+
return all_entries
|
|
63
|
+
|
|
64
|
+
def verify_chain(self) -> bool:
|
|
65
|
+
"""Returns True if no tampering detected."""
|
|
66
|
+
all_entries = self.entries()
|
|
67
|
+
prev = "genesis"
|
|
68
|
+
for entry in all_entries:
|
|
69
|
+
chain = entry.pop("chain", "")
|
|
70
|
+
payload = prev + json.dumps(entry, sort_keys=True)
|
|
71
|
+
expected = hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
72
|
+
entry["chain"] = chain
|
|
73
|
+
if chain != expected:
|
|
74
|
+
return False
|
|
75
|
+
prev = chain
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def export_csv(self) -> str:
|
|
79
|
+
buf = io.StringIO()
|
|
80
|
+
fields = ["ts", "op", "target", "ok", "detail", "actor", "chain"]
|
|
81
|
+
writer = csv.DictWriter(buf, fieldnames=fields, extrasaction="ignore")
|
|
82
|
+
writer.writeheader()
|
|
83
|
+
writer.writerows(self.entries())
|
|
84
|
+
return buf.getvalue()
|
|
85
|
+
|
|
86
|
+
def export_json(self) -> str:
|
|
87
|
+
return json.dumps(self.entries(), indent=2)
|
tawn/capability/fs.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""MediatedFS — the single filesystem chokepoint (design spec §2).
|
|
2
|
+
|
|
3
|
+
"A write outside write: is impossible by construction, not by good
|
|
4
|
+
behaviour." Every component must do file I/O through this class; direct
|
|
5
|
+
open() elsewhere in the codebase is a review-rejectable defect.
|
|
6
|
+
|
|
7
|
+
Resolution first, check second: expanduser().resolve() collapses `..`
|
|
8
|
+
and follows symlinks, so a path is judged by where it actually lands.
|
|
9
|
+
The Tawn home is implicitly readable+writable (Tawn manages its own
|
|
10
|
+
state); everything else needs an explicit grant.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from tawn.capability.audit import AuditLog
|
|
16
|
+
from tawn.capability.grants import Grants
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GrantError(PermissionError):
|
|
20
|
+
"""Access outside the granted capability surface."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MediatedFS:
|
|
24
|
+
def __init__(self, grants: Grants, audit: AuditLog, home: Path):
|
|
25
|
+
self.grants = grants
|
|
26
|
+
self.audit = audit
|
|
27
|
+
self.home = home.expanduser().resolve()
|
|
28
|
+
|
|
29
|
+
# -- policy -------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _under(path: Path, root: Path) -> bool:
|
|
33
|
+
return path == root or root in path.parents
|
|
34
|
+
|
|
35
|
+
def _resolve(self, path: Path | str) -> Path:
|
|
36
|
+
return Path(path).expanduser().resolve()
|
|
37
|
+
|
|
38
|
+
def _may_read(self, p: Path) -> bool:
|
|
39
|
+
roots = [*self.grants.read, *self.grants.write, self.home]
|
|
40
|
+
return any(self._under(p, r) for r in roots)
|
|
41
|
+
|
|
42
|
+
def _may_write(self, p: Path) -> bool:
|
|
43
|
+
roots = [*self.grants.write, self.home]
|
|
44
|
+
return any(self._under(p, r) for r in roots)
|
|
45
|
+
|
|
46
|
+
def _gate(self, op: str, p: Path, allowed: bool) -> None:
|
|
47
|
+
self.audit.record(op, str(p), ok=allowed, detail="" if allowed else "no grant")
|
|
48
|
+
if not allowed:
|
|
49
|
+
raise GrantError(f"{op} denied outside grants: {p}")
|
|
50
|
+
|
|
51
|
+
# -- operations -----------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def read_text(self, path: Path | str) -> str:
|
|
54
|
+
p = self._resolve(path)
|
|
55
|
+
self._gate("fs.read", p, self._may_read(p))
|
|
56
|
+
return p.read_text()
|
|
57
|
+
|
|
58
|
+
def write_text(self, path: Path | str, content: str) -> None:
|
|
59
|
+
p = self._resolve(path)
|
|
60
|
+
self._gate("fs.write", p, self._may_write(p))
|
|
61
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
p.write_text(content)
|
|
63
|
+
|
|
64
|
+
def list_dir(self, path: Path | str) -> list[Path]:
|
|
65
|
+
p = self._resolve(path)
|
|
66
|
+
self._gate("fs.list", p, self._may_read(p))
|
|
67
|
+
return sorted(p.iterdir())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Capability grants (design spec §2): deny-all until the user grants.
|
|
2
|
+
|
|
3
|
+
grants.yaml is user-editable, git-ignored, integrity-checked (integrity.py).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from tawn.capability.integrity import verify
|
|
12
|
+
|
|
13
|
+
#: Written by `tawn init`. Deny-all: every list empty, system off.
|
|
14
|
+
DEFAULT_GRANTS_YAML = """\
|
|
15
|
+
# ~/.tawn/grants.yaml — capability grants (deny-all by default)
|
|
16
|
+
# Edit, then run `tawn grant confirm` to accept the change.
|
|
17
|
+
# Docs: design spec §2. Every grant use is written to audit.log.
|
|
18
|
+
read: [] # read-only indexing, e.g. [~/code/projectX, ~/papers]
|
|
19
|
+
write: [] # the ONLY writable paths, e.g. [~/Obsidian/Tawn/reviews]
|
|
20
|
+
observe: [] # ambient watchers, opt-in each, e.g. [vscode, git]
|
|
21
|
+
system: false # full-system awareness; per-session opt-in (§6.4)
|
|
22
|
+
mcp: [] # enabled MCP servers, e.g. [github, gmail-ro]
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Grants(BaseModel):
|
|
27
|
+
read: list[Path] = Field(default_factory=list)
|
|
28
|
+
write: list[Path] = Field(default_factory=list)
|
|
29
|
+
observe: list[str] = Field(default_factory=list)
|
|
30
|
+
system: bool = False
|
|
31
|
+
mcp: list[str] = Field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def deny_all(cls) -> "Grants":
|
|
35
|
+
return cls()
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def load(cls, path: Path) -> "Grants":
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return cls.deny_all()
|
|
41
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
42
|
+
grants = cls.model_validate(data)
|
|
43
|
+
grants.read = [Path(p).expanduser().resolve() for p in grants.read]
|
|
44
|
+
grants.write = [Path(p).expanduser().resolve() for p in grants.write]
|
|
45
|
+
return grants
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_verified(path: Path) -> "Grants":
|
|
49
|
+
"""The only sanctioned way to obtain grants: integrity check, then load."""
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return Grants.deny_all()
|
|
52
|
+
verify(path)
|
|
53
|
+
return Grants.load(path)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""grants.yaml integrity (design spec §2: "git-ignored, integrity-checked").
|
|
2
|
+
|
|
3
|
+
SHA-256 sidecar. A hand edit is legal but must be acknowledged with
|
|
4
|
+
`tawn grant confirm`; until then Tawn refuses to load the grants.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IntegrityError(Exception):
|
|
12
|
+
"""grants.yaml changed without `tawn grant confirm` (or sidecar missing)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sidecar(path: Path) -> Path:
|
|
16
|
+
return path.with_name(path.name + ".sha256")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _digest(path: Path) -> str:
|
|
20
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def confirm(path: Path) -> str:
|
|
24
|
+
digest = _digest(path)
|
|
25
|
+
sidecar(path).write_text(digest + "\n")
|
|
26
|
+
return digest
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def verify(path: Path) -> None:
|
|
30
|
+
side = sidecar(path)
|
|
31
|
+
if not side.exists():
|
|
32
|
+
raise IntegrityError(
|
|
33
|
+
f"{side.name} missing — run `tawn grant confirm` to accept {path.name}"
|
|
34
|
+
)
|
|
35
|
+
if side.read_text().strip() != _digest(path):
|
|
36
|
+
raise IntegrityError(
|
|
37
|
+
f"{path.name} was edited since last confirm — review it, then run `tawn grant confirm`"
|
|
38
|
+
)
|