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.
Files changed (170) hide show
  1. tawn/__init__.py +3 -0
  2. tawn/_webserver.py +66 -0
  3. tawn/branding.py +82 -0
  4. tawn/capability/__init__.py +1 -0
  5. tawn/capability/audit.py +87 -0
  6. tawn/capability/fs.py +67 -0
  7. tawn/capability/grants.py +53 -0
  8. tawn/capability/integrity.py +38 -0
  9. tawn/cli.py +2183 -0
  10. tawn/compiler/__init__.py +0 -0
  11. tawn/compiler/classifier.py +79 -0
  12. tawn/compiler/compiler.py +409 -0
  13. tawn/compiler/conflicts.py +49 -0
  14. tawn/compiler/delta.py +179 -0
  15. tawn/compiler/embedder.py +208 -0
  16. tawn/compiler/entities.py +126 -0
  17. tawn/compiler/parser.py +237 -0
  18. tawn/compiler/wiki.py +197 -0
  19. tawn/config.py +26 -0
  20. tawn/db.py +107 -0
  21. tawn/dbsetup.py +60 -0
  22. tawn/domains/__init__.py +1 -0
  23. tawn/domains/academic/__init__.py +22 -0
  24. tawn/domains/base.py +33 -0
  25. tawn/domains/creation.py +104 -0
  26. tawn/domains/hobby/__init__.py +16 -0
  27. tawn/domains/records.py +112 -0
  28. tawn/domains/registry.py +116 -0
  29. tawn/domains/research/__init__.py +21 -0
  30. tawn/domains/stub.py +32 -0
  31. tawn/domains/wealth/__init__.py +11 -0
  32. tawn/domains/wealth/api.py +51 -0
  33. tawn/domains/wealth/cli.py +105 -0
  34. tawn/domains/wealth/dashboard.py +62 -0
  35. tawn/domains/wealth/holdings.py +76 -0
  36. tawn/domains/wealth/prices.py +101 -0
  37. tawn/domains/wealth/schedule.py +63 -0
  38. tawn/domains/wealth/snapshot.py +118 -0
  39. tawn/domains/work/__init__.py +17 -0
  40. tawn/federation/__init__.py +0 -0
  41. tawn/federation/adapters/__init__.py +0 -0
  42. tawn/federation/adapters/base.py +35 -0
  43. tawn/federation/adapters/chatgpt.py +59 -0
  44. tawn/federation/adapters/claude_ai.py +43 -0
  45. tawn/federation/adapters/claude_code.py +84 -0
  46. tawn/federation/adapters/codex.py +71 -0
  47. tawn/federation/adapters/gemini.py +46 -0
  48. tawn/federation/adapters/gemini_cli.py +109 -0
  49. tawn/federation/adapters/generic.py +49 -0
  50. tawn/federation/config.py +41 -0
  51. tawn/federation/discovery.py +122 -0
  52. tawn/federation/dispatcher.py +43 -0
  53. tawn/federation/exporter.py +112 -0
  54. tawn/federation/merge.py +172 -0
  55. tawn/federation/normalizer.py +151 -0
  56. tawn/federation/schema.py +38 -0
  57. tawn/federation/systemd.py +128 -0
  58. tawn/federation/watcher.py +98 -0
  59. tawn/history.py +80 -0
  60. tawn/home.py +93 -0
  61. tawn/ignore.py +183 -0
  62. tawn/mcp_server.py +75 -0
  63. tawn/memory/__init__.py +0 -0
  64. tawn/memory/brief.py +72 -0
  65. tawn/memory/note.py +62 -0
  66. tawn/memory/recall.py +175 -0
  67. tawn/memory/schema.py +154 -0
  68. tawn/model/__init__.py +1 -0
  69. tawn/model/breaker.py +57 -0
  70. tawn/model/catalog.py +106 -0
  71. tawn/model/directory.py +95 -0
  72. tawn/model/identity.py +44 -0
  73. tawn/model/keys.py +52 -0
  74. tawn/model/ledger.py +93 -0
  75. tawn/model/personality.py +59 -0
  76. tawn/model/providers/__init__.py +1 -0
  77. tawn/model/providers/anthropic.py +127 -0
  78. tawn/model/providers/gemini.py +138 -0
  79. tawn/model/providers/ollama.py +149 -0
  80. tawn/model/providers/openai_compat.py +156 -0
  81. tawn/model/router.py +346 -0
  82. tawn/model/types.py +72 -0
  83. tawn/updater.py +179 -0
  84. tawn/user_config.py +124 -0
  85. tawn/web/__init__.py +3 -0
  86. tawn/web/app.py +290 -0
  87. tawn/web/dist/assets/abnfDiagram-VRR7QNED-C5F_AKgo.js +1 -0
  88. tawn/web/dist/assets/arc-DfuEWumi.js +1 -0
  89. tawn/web/dist/assets/architectureDiagram-ZJ3FMSHR-CMdbMQ-v.js +36 -0
  90. tawn/web/dist/assets/blockDiagram-677ZJIJ3-BvWKvlkz.js +132 -0
  91. tawn/web/dist/assets/c4Diagram-LMCZKHZV-cmtcJnEh.js +10 -0
  92. tawn/web/dist/assets/channel-1wBTkNV8.js +1 -0
  93. tawn/web/dist/assets/chunk-2Q5K7J3B-CVkZvg6J.js +1 -0
  94. tawn/web/dist/assets/chunk-32BRIVSS-BJnUTK_p.js +1 -0
  95. tawn/web/dist/assets/chunk-5VM5RSS4-BgCFFy0k.js +15 -0
  96. tawn/web/dist/assets/chunk-EX3LRPZG-D3opRKGp.js +231 -0
  97. tawn/web/dist/assets/chunk-JWPE2WC7-BQBJybxG.js +1 -0
  98. tawn/web/dist/assets/chunk-MOJQB5TN-DPCuIYGK.js +88 -0
  99. tawn/web/dist/assets/chunk-RYQCIY6F-BGrEIzMk.js +1 -0
  100. tawn/web/dist/assets/chunk-V7JOEXUC-16qlVcj5.js +206 -0
  101. tawn/web/dist/assets/chunk-VR4S4FIN-Dri-A7dn.js +1 -0
  102. tawn/web/dist/assets/chunk-XXDRQBXY-BZJRsKXY.js +1 -0
  103. tawn/web/dist/assets/classDiagram-OUVF2IWQ-CidNYYIR.js +1 -0
  104. tawn/web/dist/assets/classDiagram-v2-EOCWNBFH-CidNYYIR.js +1 -0
  105. tawn/web/dist/assets/cose-bilkent-JH36ORCC-B7ClDWJL.js +1 -0
  106. tawn/web/dist/assets/cynefin-VYW2F7L2-DdT7ZLm1.js +178 -0
  107. tawn/web/dist/assets/cynefinDiagram-TSTJHNR4-D3XVPeTI.js +62 -0
  108. tawn/web/dist/assets/cytoscape.esm-CUqq0XTU.js +331 -0
  109. tawn/web/dist/assets/dagre-VKFMJZFB-C7hI6zFs.js +4 -0
  110. tawn/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  111. tawn/web/dist/assets/diagram-FQU43EPY-BwkzyqxA.js +3 -0
  112. tawn/web/dist/assets/diagram-G47NLZAW-BNQLclaM.js +24 -0
  113. tawn/web/dist/assets/diagram-NH7WQ7WH-C89oedn8.js +24 -0
  114. tawn/web/dist/assets/diagram-OA4YK3LP-CM_ra1CZ.js +30 -0
  115. tawn/web/dist/assets/diagram-WEI45ONY-CIyDX7LQ.js +41 -0
  116. tawn/web/dist/assets/ebnfDiagram-CCIWWBDH-CStWDurL.js +1 -0
  117. tawn/web/dist/assets/erDiagram-Q63AITRT-BwAeP1e2.js +85 -0
  118. tawn/web/dist/assets/flowDiagram-23GEKE2U-CAA-JOsf.js +156 -0
  119. tawn/web/dist/assets/ganttDiagram-NO4QXBWP-7bajtVpE.js +292 -0
  120. tawn/web/dist/assets/gitGraphDiagram-IHSO6WYX-qW76mQDo.js +106 -0
  121. tawn/web/dist/assets/graph-C2PyVmSI.js +1 -0
  122. tawn/web/dist/assets/index-BaAvK0kS.js +162 -0
  123. tawn/web/dist/assets/index-CPMCxrJ6.js +3 -0
  124. tawn/web/dist/assets/index-DMnyYUiC.css +1 -0
  125. tawn/web/dist/assets/infoDiagram-FWYZ7A6U-BN3TAAMm.js +2 -0
  126. tawn/web/dist/assets/init-Gi6I4Gst.js +1 -0
  127. tawn/web/dist/assets/ishikawaDiagram-FXEZZL3T-DIjUQHYT.js +70 -0
  128. tawn/web/dist/assets/journeyDiagram-5HDEW3XC-Cd_0i_MI.js +139 -0
  129. tawn/web/dist/assets/kanban-definition-HUTT4EX6-EJykSLKC.js +89 -0
  130. tawn/web/dist/assets/katex-C5jXJg4s.js +257 -0
  131. tawn/web/dist/assets/layout-HEHzrzvT.js +1 -0
  132. tawn/web/dist/assets/linear-DZV_74YI.js +1 -0
  133. tawn/web/dist/assets/map-BQHxRNoK.js +1 -0
  134. tawn/web/dist/assets/mermaid.core-Cs_D-MaZ.js +314 -0
  135. tawn/web/dist/assets/mindmap-definition-LN4V7U3C-Dayioghr.js +96 -0
  136. tawn/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  137. tawn/web/dist/assets/pegDiagram-2B236MQR-CEg98Wsy.js +1 -0
  138. tawn/web/dist/assets/pieDiagram-ENE6RG2P-BWzY0gy2.js +39 -0
  139. tawn/web/dist/assets/quadrantDiagram-ABIIQ3AL-GoCE2r_5.js +7 -0
  140. tawn/web/dist/assets/railroadDiagram-RFXS5EU6-BxTtKfWT.js +1 -0
  141. tawn/web/dist/assets/requirementDiagram-TGXJPOKE-CD0t2ic5.js +84 -0
  142. tawn/web/dist/assets/sankeyDiagram-HTMAVEWB-Om9xv51_.js +40 -0
  143. tawn/web/dist/assets/sequenceDiagram-DBY2YBRQ-Ct9pa12Y.js +162 -0
  144. tawn/web/dist/assets/sizeCapture-X5ZJPWSS-D0lniTrU.js +1 -0
  145. tawn/web/dist/assets/stateDiagram-2N3HPSRC-C3JQoYro.js +1 -0
  146. tawn/web/dist/assets/stateDiagram-v2-6OUMAXLB-s_yFnFGS.js +1 -0
  147. tawn/web/dist/assets/swimlanes-5IMT3BWC-B0FKBUkt.js +2 -0
  148. tawn/web/dist/assets/swimlanesDiagram-G3AALYLV-aVA3YxQh.js +8 -0
  149. tawn/web/dist/assets/timeline-definition-FHXFAJF6-MhKDYH6H.js +120 -0
  150. tawn/web/dist/assets/vennDiagram-L72KCM5P-CqvlQmOm.js +34 -0
  151. tawn/web/dist/assets/wardleyDiagram-EHGQE667-DdS-6LAr.js +78 -0
  152. tawn/web/dist/assets/xychartDiagram-FW5EYKEG-DpAi9dnU.js +7 -0
  153. tawn/web/dist/favicon.svg +10 -0
  154. tawn/web/dist/index.html +31 -0
  155. tawn/web/routes/__init__.py +0 -0
  156. tawn/web/routes/chat.py +231 -0
  157. tawn/web/routes/domains.py +85 -0
  158. tawn/web/routes/federation.py +411 -0
  159. tawn/web/routes/grants.py +118 -0
  160. tawn/web/routes/history.py +23 -0
  161. tawn/web/routes/memory.py +276 -0
  162. tawn/web/routes/profile.py +29 -0
  163. tawn/web/routes/setup.py +100 -0
  164. tawn/web/routes/update.py +47 -0
  165. tawn-0.1.0.dist-info/METADATA +201 -0
  166. tawn-0.1.0.dist-info/RECORD +170 -0
  167. tawn-0.1.0.dist-info/WHEEL +5 -0
  168. tawn-0.1.0.dist-info/entry_points.txt +9 -0
  169. tawn-0.1.0.dist-info/licenses/LICENSE +21 -0
  170. tawn-0.1.0.dist-info/top_level.txt +1 -0
tawn/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Tawn — the personal digital twin. Capability-gated memory core."""
2
+
3
+ __version__ = "0.1.0"
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."""
@@ -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
+ )