@tech-leads-club/harness-toolkit 0.2.1

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 (300) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/LICENSE +84 -0
  3. package/NOTICE +9 -0
  4. package/README.md +480 -0
  5. package/bin/tlc +10 -0
  6. package/bin/tlc-build +80 -0
  7. package/bin/tlc-cli.ts +1509 -0
  8. package/bin/tlc-exec +10 -0
  9. package/bin/tlc-exec.cmd +4 -0
  10. package/bin/tlc-exec.d.mts +60 -0
  11. package/bin/tlc-exec.mjs +248 -0
  12. package/bin/tlc.cmd +4 -0
  13. package/bin/tlc.mjs +7 -0
  14. package/bin/write-user-hooks.d.mts +40 -0
  15. package/bin/write-user-hooks.mjs +123 -0
  16. package/capabilities/catalog.json +418 -0
  17. package/config.example.json +51 -0
  18. package/dist/compact-before.mjs +7839 -0
  19. package/dist/doctor.mjs +8263 -0
  20. package/dist/help-topic.mjs +82 -0
  21. package/dist/init-project.mjs +993 -0
  22. package/dist/install-runtime.mjs +969 -0
  23. package/dist/lessons-cli.mjs +7258 -0
  24. package/dist/obs-cli.mjs +7025 -0
  25. package/dist/price-lookup.mjs +221 -0
  26. package/dist/prompt-submit.mjs +7860 -0
  27. package/dist/refresh-model-prices.mjs +158 -0
  28. package/dist/response-after.mjs +7879 -0
  29. package/dist/run.mjs +7830 -0
  30. package/dist/session-end.mjs +7937 -0
  31. package/dist/session-start.mjs +8056 -0
  32. package/dist/shim.mjs +52 -0
  33. package/dist/stop.mjs +8494 -0
  34. package/dist/subagent-start.mjs +7861 -0
  35. package/dist/subagent-stop.mjs +7851 -0
  36. package/dist/support.mjs +7045 -0
  37. package/dist/tlc-cli.mjs +8063 -0
  38. package/dist/tool-after.mjs +8113 -0
  39. package/dist/tool-before.mjs +7985 -0
  40. package/dist/tool-failure.mjs +7850 -0
  41. package/dist/uninstall-runtime.mjs +1307 -0
  42. package/docs/architecture.md +270 -0
  43. package/docs/concepts.md +562 -0
  44. package/docs/coverage.md +55 -0
  45. package/docs/decisions/ad-001.md +32 -0
  46. package/docs/decisions/ad-002.md +51 -0
  47. package/docs/decisions/ad-003.md +30 -0
  48. package/docs/decisions/ad-004.md +37 -0
  49. package/docs/decisions/ad-005.md +36 -0
  50. package/docs/decisions/ad-006.md +49 -0
  51. package/docs/decisions/ad-007.md +36 -0
  52. package/docs/decisions/ad-008.md +54 -0
  53. package/docs/decisions/ad-009.md +61 -0
  54. package/docs/decisions/ad-010.md +45 -0
  55. package/docs/decisions/ad-011.md +59 -0
  56. package/docs/decisions/ad-012.md +71 -0
  57. package/docs/decisions/ad-013.md +87 -0
  58. package/docs/decisions/ad-014.md +56 -0
  59. package/docs/decisions/ad-015.md +33 -0
  60. package/docs/decisions/ad-016.md +98 -0
  61. package/docs/decisions/ad-017.md +65 -0
  62. package/docs/decisions/ad-018.md +77 -0
  63. package/docs/decisions/ad-019.md +75 -0
  64. package/docs/decisions/ad-020.md +88 -0
  65. package/docs/decisions/ad-021.md +57 -0
  66. package/docs/decisions/ad-022.md +120 -0
  67. package/docs/decisions/ad-023.md +87 -0
  68. package/docs/decisions/ad-024.md +85 -0
  69. package/docs/decisions/ad-025.md +92 -0
  70. package/docs/decisions/ad-026.md +131 -0
  71. package/docs/decisions/ad-027.md +90 -0
  72. package/docs/decisions/ad-028.md +90 -0
  73. package/docs/decisions/ad-029.md +73 -0
  74. package/docs/decisions/ad-030.md +97 -0
  75. package/docs/decisions/ad-031.md +94 -0
  76. package/docs/decisions/ad-032.md +83 -0
  77. package/docs/decisions/ad-033.md +89 -0
  78. package/docs/decisions/ad-034.md +86 -0
  79. package/docs/decisions/ad-035.md +86 -0
  80. package/docs/decisions/ad-036.md +68 -0
  81. package/docs/decisions/ad-037.md +47 -0
  82. package/docs/decisions/ad-038.md +52 -0
  83. package/docs/decisions/ad-039.md +69 -0
  84. package/docs/decisions/ad-040.md +89 -0
  85. package/docs/decisions/ad-041.md +98 -0
  86. package/docs/decisions/ad-042.md +82 -0
  87. package/docs/decisions/ad-043.md +79 -0
  88. package/docs/decisions/ad-044.md +61 -0
  89. package/docs/decisions/ad-045.md +94 -0
  90. package/docs/decisions/ad-046.md +111 -0
  91. package/docs/decisions/ad-047.md +96 -0
  92. package/docs/decisions/ad-048.md +85 -0
  93. package/docs/decisions/ad-049.md +66 -0
  94. package/docs/decisions/ad-050.md +94 -0
  95. package/docs/decisions/ad-051.md +69 -0
  96. package/docs/decisions/ad-052.md +69 -0
  97. package/docs/decisions/ad-053.md +78 -0
  98. package/docs/decisions/ad-054.md +98 -0
  99. package/docs/decisions/ad-055.md +74 -0
  100. package/docs/decisions/ad-056.md +85 -0
  101. package/docs/decisions/ad-057.md +68 -0
  102. package/docs/decisions/ad-058.md +97 -0
  103. package/docs/decisions/ad-059.md +82 -0
  104. package/docs/decisions/ad-060.md +75 -0
  105. package/docs/decisions/ad-061.md +68 -0
  106. package/docs/decisions/ad-062.md +72 -0
  107. package/docs/decisions/ad-063.md +84 -0
  108. package/docs/decisions/ad-064.md +79 -0
  109. package/docs/decisions/ad-065.md +81 -0
  110. package/docs/decisions/ad-066.md +111 -0
  111. package/docs/decisions/ad-067.md +64 -0
  112. package/docs/decisions/ad-068.md +79 -0
  113. package/docs/decisions/ad-069.md +74 -0
  114. package/docs/decisions/ad-070.md +86 -0
  115. package/docs/decisions/ad-071.md +93 -0
  116. package/docs/decisions/ad-072.md +82 -0
  117. package/docs/decisions/ad-073.md +102 -0
  118. package/docs/decisions/ad-074.md +91 -0
  119. package/docs/decisions/ad-075.md +79 -0
  120. package/docs/decisions/ad-076.md +102 -0
  121. package/docs/decisions/ad-077.md +94 -0
  122. package/docs/decisions/ad-078.md +84 -0
  123. package/docs/decisions/ad-079.md +73 -0
  124. package/docs/decisions/ad-080.md +86 -0
  125. package/docs/decisions/ad-081.md +70 -0
  126. package/docs/decisions/ad-082.md +79 -0
  127. package/docs/decisions/ad-083.md +88 -0
  128. package/docs/decisions/index.md +111 -0
  129. package/docs/diagnose.md +248 -0
  130. package/docs/index.md +52 -0
  131. package/docs/init.md +101 -0
  132. package/docs/lessons.md +294 -0
  133. package/docs/log.md +139 -0
  134. package/docs/measure.md +178 -0
  135. package/docs/providers/claude-code.md +118 -0
  136. package/docs/providers/cursor.md +110 -0
  137. package/docs/providers/index.md +71 -0
  138. package/docs/troubleshooting.md +138 -0
  139. package/model-aliases.json +12 -0
  140. package/model-prices.cursor.json +410 -0
  141. package/model-prices.json +1 -0
  142. package/package.json +57 -0
  143. package/skills/harness-init/SKILL.md +241 -0
  144. package/skills/harness-init/evals/trigger_evals.json +86 -0
  145. package/skills/harness-init/references/capabilities.md +113 -0
  146. package/src/contracts/capabilities.ts +30 -0
  147. package/src/contracts/decision.ts +21 -0
  148. package/src/contracts/effort.ts +15 -0
  149. package/src/contracts/harness-event.ts +87 -0
  150. package/src/contracts/index.ts +6 -0
  151. package/src/contracts/policy-defaults.ts +14 -0
  152. package/src/contracts/wiring.ts +20 -0
  153. package/src/core/attest/attest.service.ts +0 -0
  154. package/src/core/capability/capability.service.ts +62 -0
  155. package/src/core/capability/capability.store.ts +51 -0
  156. package/src/core/capability/capability.types.ts +56 -0
  157. package/src/core/comment-policy/comment-policy.service.ts +307 -0
  158. package/src/core/comment-policy/comment-policy.types.ts +6 -0
  159. package/src/core/comment-policy/comment-resolvability.ts +134 -0
  160. package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
  161. package/src/core/comment-policy/comment-syntax.store.ts +65 -0
  162. package/src/core/comment-policy/comment-syntax.types.ts +24 -0
  163. package/src/core/core.facade.ts +461 -0
  164. package/src/core/duplication/duplication.service.ts +219 -0
  165. package/src/core/floor/floor.catalog.ts +54 -0
  166. package/src/core/floor/floor.paths.ts +90 -0
  167. package/src/core/floor/floor.policy-surface.ts +311 -0
  168. package/src/core/floor/floor.service.ts +306 -0
  169. package/src/core/floor/floor.tokenize.ts +179 -0
  170. package/src/core/floor/floor.verb.ts +28 -0
  171. package/src/core/floor/index.ts +1 -0
  172. package/src/core/gate/gate.artifact.ts +157 -0
  173. package/src/core/gate/gate.command.ts +130 -0
  174. package/src/core/gate/gate.findings.ts +201 -0
  175. package/src/core/gate/gate.inputs.ts +0 -0
  176. package/src/core/gate/gate.lock.ts +283 -0
  177. package/src/core/gate/gate.service.ts +28 -0
  178. package/src/core/gate/gate.types.ts +67 -0
  179. package/src/core/handoff/handoff.service.ts +43 -0
  180. package/src/core/handoff/handoff.store.ts +59 -0
  181. package/src/core/handoff/handoff.types.ts +89 -0
  182. package/src/core/index.ts +52 -0
  183. package/src/core/integrity/state-seal.ts +81 -0
  184. package/src/core/lesson/lesson.authored.ts +96 -0
  185. package/src/core/lesson/lesson.credit.ts +51 -0
  186. package/src/core/lesson/lesson.garden.ts +283 -0
  187. package/src/core/lesson/lesson.link.ts +93 -0
  188. package/src/core/lesson/lesson.score.ts +61 -0
  189. package/src/core/lesson/lesson.select.ts +209 -0
  190. package/src/core/lesson/lesson.service.ts +110 -0
  191. package/src/core/lesson/lesson.store.ts +317 -0
  192. package/src/core/lesson/lesson.sync.ts +70 -0
  193. package/src/core/lesson/lesson.types.ts +92 -0
  194. package/src/core/lesson/lesson.validity.ts +44 -0
  195. package/src/core/observability/observability.report.ts +326 -0
  196. package/src/core/observability/observability.service.ts +328 -0
  197. package/src/core/observability/observability.store.ts +269 -0
  198. package/src/core/observability/observability.types.ts +193 -0
  199. package/src/core/observability/observability.why.ts +206 -0
  200. package/src/core/observe/observe.service.ts +76 -0
  201. package/src/core/plan/plan.detect.ts +47 -0
  202. package/src/core/plan/plan.service.ts +81 -0
  203. package/src/core/plan/plan.types.ts +25 -0
  204. package/src/core/policy/policy.defaults.ts +103 -0
  205. package/src/core/policy/policy.guard.ts +34 -0
  206. package/src/core/policy/policy.integrity.ts +238 -0
  207. package/src/core/policy/policy.loader.ts +118 -0
  208. package/src/core/policy/policy.operator.ts +82 -0
  209. package/src/core/policy/policy.posture.ts +79 -0
  210. package/src/core/policy/policy.rails.ts +45 -0
  211. package/src/core/policy/policy.types.ts +157 -0
  212. package/src/core/presence/presence.service.ts +111 -0
  213. package/src/core/presence/presence.store.ts +59 -0
  214. package/src/core/presence/presence.types.ts +9 -0
  215. package/src/core/release/release.decisions.ts +147 -0
  216. package/src/core/release/release.seen.ts +38 -0
  217. package/src/core/shell-policy/shell-policy.service.ts +215 -0
  218. package/src/core/shell-policy/shell-policy.stall.ts +57 -0
  219. package/src/core/shell-policy/shell-policy.types.ts +26 -0
  220. package/src/core/ship/ship.ledger.ts +76 -0
  221. package/src/core/ship/ship.service.ts +137 -0
  222. package/src/core/ship/ship.types.ts +19 -0
  223. package/src/core/stagnation/stagnation.resolution.ts +87 -0
  224. package/src/core/stagnation/stagnation.service.ts +20 -0
  225. package/src/core/stagnation/stagnation.store.ts +49 -0
  226. package/src/core/stagnation/stagnation.types.ts +12 -0
  227. package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
  228. package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
  229. package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
  230. package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
  231. package/src/core/supply-chain/supply-chain.service.ts +217 -0
  232. package/src/core/turn/turn.activity.ts +94 -0
  233. package/src/core/turn/turn.autopilot.ts +131 -0
  234. package/src/core/turn/turn.failure-signals.ts +193 -0
  235. package/src/core/turn/turn.loop-counter.ts +71 -0
  236. package/src/core/turn/turn.types.ts +14 -0
  237. package/src/core/untrusted/untrusted.detect.ts +53 -0
  238. package/src/core/untrusted/untrusted.recall.ts +87 -0
  239. package/src/core/untrusted/untrusted.service.ts +129 -0
  240. package/src/core/untrusted/untrusted.store.ts +75 -0
  241. package/src/core/untrusted/untrusted.types.ts +42 -0
  242. package/src/entrypoints/compact-before.ts +14 -0
  243. package/src/entrypoints/prompt-submit.ts +25 -0
  244. package/src/entrypoints/response-after.ts +60 -0
  245. package/src/entrypoints/run.ts +179 -0
  246. package/src/entrypoints/session-end.ts +58 -0
  247. package/src/entrypoints/session-start.ts +172 -0
  248. package/src/entrypoints/shim.ts +46 -0
  249. package/src/entrypoints/stop.ts +876 -0
  250. package/src/entrypoints/subagent-start.ts +16 -0
  251. package/src/entrypoints/subagent-stop.ts +32 -0
  252. package/src/entrypoints/support.ts +182 -0
  253. package/src/entrypoints/tool-after.ts +111 -0
  254. package/src/entrypoints/tool-before.ts +186 -0
  255. package/src/entrypoints/tool-failure.ts +26 -0
  256. package/src/platform/backoff.ts +49 -0
  257. package/src/platform/cli-output.ts +30 -0
  258. package/src/platform/env-scope.ts +22 -0
  259. package/src/platform/fs-atomic.ts +142 -0
  260. package/src/platform/fs-jsonl.ts +24 -0
  261. package/src/platform/git.ts +187 -0
  262. package/src/platform/paths.ts +80 -0
  263. package/src/platform/pricing.ts +229 -0
  264. package/src/platform/process.ts +64 -0
  265. package/src/platform/sanitize.ts +23 -0
  266. package/src/platform/screen.ts +46 -0
  267. package/src/platform/style.ts +103 -0
  268. package/src/providers/claude/claude.capabilities.ts +25 -0
  269. package/src/providers/claude/claude.detect.ts +14 -0
  270. package/src/providers/claude/claude.inbound.ts +252 -0
  271. package/src/providers/claude/claude.lessons-view.ts +33 -0
  272. package/src/providers/claude/claude.outbound.ts +65 -0
  273. package/src/providers/claude/claude.policy-defaults.ts +9 -0
  274. package/src/providers/claude/claude.transcript.ts +62 -0
  275. package/src/providers/claude/claude.wiring.ts +245 -0
  276. package/src/providers/claude/index.ts +17 -0
  277. package/src/providers/cursor/cursor.capabilities.ts +28 -0
  278. package/src/providers/cursor/cursor.detect.ts +14 -0
  279. package/src/providers/cursor/cursor.inbound.ts +203 -0
  280. package/src/providers/cursor/cursor.lessons-view.ts +30 -0
  281. package/src/providers/cursor/cursor.outbound.ts +43 -0
  282. package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
  283. package/src/providers/cursor/cursor.wiring.ts +214 -0
  284. package/src/providers/cursor/index.ts +17 -0
  285. package/src/providers/index.ts +9 -0
  286. package/src/providers/provider.degrade.ts +152 -0
  287. package/src/providers/provider.port.ts +20 -0
  288. package/src/providers/provider.registry.ts +28 -0
  289. package/tools/doctor.ts +631 -0
  290. package/tools/help-topic.ts +74 -0
  291. package/tools/init-project.ts +277 -0
  292. package/tools/install-runtime.ts +164 -0
  293. package/tools/lessons-cli.ts +474 -0
  294. package/tools/obs-cli.ts +175 -0
  295. package/tools/price-lookup.ts +57 -0
  296. package/tools/refresh-model-prices.ts +210 -0
  297. package/tools/test-env.mjs +28 -0
  298. package/tools/test-env.names.d.mts +1 -0
  299. package/tools/test-env.names.mjs +14 -0
  300. package/tools/uninstall-runtime.ts +463 -0
@@ -0,0 +1,54 @@
1
+ import type { FloorRule } from "./floor.service.ts";
2
+
3
+ export type FloorRuleDoc = {
4
+ /** What the rule refuses, in the terms an operator reading a denial would use. */
5
+ denies: string;
6
+ /** The one case that reads like it should be denied and is not, where there is one worth stating. */
7
+ allows?: string;
8
+ };
9
+
10
+ /**
11
+ * why: the README described the floor as "five rules" above a table of six, and `docs/architecture.md` listed
12
+ * eight — two of which are not floor rules at all. The floor is the part no configuration can reach, so a
13
+ * hand-copied count of it is the worst place in the product for a number to drift.
14
+ *
15
+ * invariant: keyed by `FloorRule`, so adding a member to that union without describing it here fails the
16
+ * typecheck rather than shipping an undocumented rule.
17
+ */
18
+ export const FLOOR_RULES: Record<FloorRule, FloorRuleDoc> = {
19
+ "outside-project-destruction": {
20
+ denies:
21
+ "a destructive command whose target resolves outside the repository and outside the OS temp directory",
22
+ allows: "the same command inside the repository, or inside the temp directory",
23
+ },
24
+ "unprovable-destruction": {
25
+ denies:
26
+ "a destructive verb whose target is a variable, a command substitution, or otherwise built at runtime — the harness cannot see what it would delete",
27
+ allows: "a literal path it can resolve and check",
28
+ },
29
+ "secret-access": {
30
+ denies:
31
+ "a read that would copy a credential into the transcript — `.env`, `~/.ssh`, `~/.aws`, `*.pem` and similar through a shell reader or the editor's own read tool, and the instance metadata service through any verb that speaks to the network",
32
+ allows: "searching local files for the literal address, because `grep` and its kin make no request",
33
+ },
34
+ "history-rewrite": {
35
+ denies: "`git push --force`",
36
+ allows: "`--force-with-lease`, which refuses on its own when the remote moved",
37
+ },
38
+ "machine-control": {
39
+ denies: "`shutdown`, `reboot`, `halt`, `poweroff`",
40
+ },
41
+ "unprovable-execution": {
42
+ denies:
43
+ "a program fetched over the network and handed to a shell — piped, process-substituted, or inside a shell's `-c`/`eval` substitution. The gate cannot read what would run",
44
+ allows: "a fetch with no shell downstream, and a shell fed a local file the gate can read",
45
+ },
46
+ "policy-surface-write": {
47
+ denies:
48
+ "every route an agent has to harness policy and state — a shell redirect, an interpreter, a heredoc program, or a write tool — in the project and under the runtime home, plus the mutating `tlc harness` subcommands from inside a session",
49
+ allows:
50
+ "reading them with a proven reader (`cat`, `head`, `grep`, `jq`, `ls`, `stat`, `test`), and `tlc harness handoff` for the handoff state",
51
+ },
52
+ };
53
+
54
+ export const FLOOR_RULE_IDS = Object.keys(FLOOR_RULES) as FloorRule[];
@@ -0,0 +1,90 @@
1
+ import { homedir, tmpdir } from "node:os";
2
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import {
4
+ flagsDir,
5
+ projectConfigPath,
6
+ projectStateDir,
7
+ runtimeHome,
8
+ runtimeStateDir,
9
+ } from "../../platform/paths.ts";
10
+ import { normalizeSeparators } from "../../platform/sanitize.ts";
11
+
12
+ export function expandHome(text: string, home = homedir()): string {
13
+ if (text === "~") {
14
+ return home;
15
+ }
16
+ return text.startsWith(`~${sep}`) || text.startsWith("~/") ? resolve(home, text.slice(2)) : text;
17
+ }
18
+
19
+ export function resolveTarget(projectDir: string, word: string, home = homedir()): string {
20
+ const expanded = expandHome(word, home);
21
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(projectDir, expanded);
22
+ }
23
+
24
+ export function isInside(parent: string, child: string): boolean {
25
+ const rel = relative(resolve(parent), resolve(child));
26
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
27
+ }
28
+
29
+ // why: scratch space is where an agent is supposed to make a mess, so destruction there is not a floor
30
+ // concern even though it sits outside the project.
31
+ export function isScratch(target: string, tmp = tmpdir()): boolean {
32
+ return isInside(tmp, target);
33
+ }
34
+
35
+ // hazard: the runtime config is merged by `loadPolicy` under the project one, so a field the project does
36
+ // not set is decided there — for every repository on the machine. Guarding only the project paths left
37
+ // `echo '{}' > ~/.tlc/harness/config.json` allowed, which is the same defect one directory up.
38
+ export function isRuntimePolicySurface(filePath: string): boolean {
39
+ const target = resolve(filePath);
40
+ return target === resolve(runtimeHome(), "config.json") || isInside(runtimeStateDir(), target);
41
+ }
42
+
43
+ // invariant: the policy surface is defined here, next to the floor's other path predicates, because the
44
+ // floor decides before any policy is read. Defining it inside the policy module would point the dependency
45
+ // backwards — against the order the two actually run in.
46
+ export function isPolicySurface(projectDir: string, filePath: string): boolean {
47
+ if (isRuntimePolicySurface(filePath)) {
48
+ return true;
49
+ }
50
+ const target = normalizeSeparators(relative(projectDir, filePath) || filePath);
51
+ const config = normalizeSeparators(relative(projectDir, projectConfigPath(projectDir)));
52
+ const flags = normalizeSeparators(relative(projectDir, flagsDir(projectDir)));
53
+ const state = normalizeSeparators(relative(projectDir, projectStateDir(projectDir)));
54
+ return target === config || target.startsWith(`${flags}/`) || target.startsWith(`${state}/`);
55
+ }
56
+
57
+ const SECRET_HOME_DIRS = [".ssh", ".aws", ".kube", ".gnupg", ".docker", ".config/gh", ".config/gcloud"];
58
+ const SECRET_BASENAMES = new Set([
59
+ ".git-credentials",
60
+ ".netrc",
61
+ ".npmrc",
62
+ ".pgpass",
63
+ "credentials",
64
+ "id_dsa",
65
+ "id_ecdsa",
66
+ "id_ed25519",
67
+ "id_rsa",
68
+ ]);
69
+ const SECRET_SUFFIXES = [".pem", ".p12", ".pfx"];
70
+ // invariant: these are templates checked into repos on purpose — treating them as secrets would
71
+ // block ordinary work and teach the operator to distrust the floor.
72
+ const ENV_TEMPLATE_SUFFIXES = [".example", ".sample", ".template", ".dist"];
73
+
74
+ function isEnvFile(name: string): boolean {
75
+ if (name !== ".env" && !name.startsWith(".env.")) {
76
+ return false;
77
+ }
78
+ return !ENV_TEMPLATE_SUFFIXES.some((suffix) => name.endsWith(suffix));
79
+ }
80
+
81
+ export function isSecretPath(target: string, home = homedir()): boolean {
82
+ const name = basename(target);
83
+ if (isEnvFile(name) || SECRET_BASENAMES.has(name)) {
84
+ return true;
85
+ }
86
+ if (SECRET_SUFFIXES.some((suffix) => name.endsWith(suffix))) {
87
+ return true;
88
+ }
89
+ return SECRET_HOME_DIRS.some((dir) => isInside(resolve(home, dir), target));
90
+ }
@@ -0,0 +1,311 @@
1
+ import { relative, resolve } from "node:path";
2
+ import { projectConfigPath, projectStateDir } from "../../platform/paths.ts";
3
+ import { normalizeSeparators } from "../../platform/sanitize.ts";
4
+ import { isInside, isPolicySurface, resolveTarget } from "./floor.paths.ts";
5
+ import {
6
+ type HeredocChunk,
7
+ heredocChunks,
8
+ type ShellSegment,
9
+ type ShellWord,
10
+ tokenizeShell,
11
+ } from "./floor.tokenize.ts";
12
+ import { firstOperand, verbOf } from "./floor.verb.ts";
13
+
14
+ /**
15
+ * `remedy` is the way out for *this* denial.
16
+ *
17
+ * hazard: every policy-surface refusal used to end with the same sentence — set a gate command, make policy changes
18
+ * from your own terminal. That is advice about *writing* policy, handed to an agent that was trying to *read* the
19
+ * handoff the harness had just told it to read. A refusal a model cannot plan around is the opaque-refusal failure
20
+ * the 2026 tool-use literature names, and it is what confused a colleague's agent
21
+ * ([/decisions/ad-047.md](/decisions/ad-047.md)).
22
+ */
23
+ export type PolicySurfaceVerdict =
24
+ | { kind: "allow" }
25
+ | { kind: "deny"; detail: string; note: string; remedy?: string };
26
+
27
+ const ALLOW: PolicySurfaceVerdict = { kind: "allow" };
28
+
29
+ // invariant: this is an allowlist on purpose. The set of ways a shell can write a file is unbounded —
30
+ // `python3 -c`, `perl -pi`, `ex`, any interpreter — so enumerating writers guarantees a hole. Enumerating
31
+ // the readers instead means an unknown verb lands on the deny side without anyone having to predict it.
32
+ // hazard: `awk` and `sort` look like readers and are not — `awk '{print > f}'` and `sort -o f` both write a
33
+ // file the head verb never reveals. They are left out deliberately.
34
+ const PROVEN_READERS = new Set([
35
+ "cat",
36
+ "cmp",
37
+ "diff",
38
+ "echo",
39
+ "file",
40
+ "grep",
41
+ "head",
42
+ "jq",
43
+ "less",
44
+ "ls",
45
+ "md5sum",
46
+ "more",
47
+ "od",
48
+ "printf",
49
+ "rg",
50
+ "sha256sum",
51
+ "stat",
52
+ "strings",
53
+ "tail",
54
+ // why: `test` and `[` evaluate a predicate and produce an exit code. They have no way to write a file at all —
55
+ // no output flag, no redirection of their own — so they are strictly safer than `echo`, which is already here.
56
+ // Their absence was an incomplete allowlist rather than a decision: the harness tells an agent to read the
57
+ // handoff, and `test -f handoff.json && head -c 2000 handoff.json` — the obvious way to do it — was denied
58
+ // ([/decisions/ad-047.md](/decisions/ad-047.md)).
59
+ "test",
60
+ "[",
61
+ "wc",
62
+ "xxd",
63
+ ]);
64
+
65
+ // hazard: `git checkout -- <path>`, `git restore` and `git apply` overwrite the working tree, so `git` as a
66
+ // whole cannot be a reader. Only the subcommands that provably do not write are allowed.
67
+ const GIT_READERS = new Set(["show", "diff", "log", "status", "ls-files", "cat-file", "blame"]);
68
+
69
+ // hazard: a heredoc is only a program when the verb it feeds executes what it reads. `git commit -F -` and
70
+ // `cat <<EOF` take a heredoc that *documents* this path — commit messages and docs in this repository name
71
+ // it constantly — so judging a body by "not a proven reader" denied writing about the rule at all.
72
+ const EXECUTES_STDIN = new Set([
73
+ "ash",
74
+ "awk",
75
+ "bash",
76
+ "bun",
77
+ "dash",
78
+ "deno",
79
+ "ed",
80
+ "ex",
81
+ "fish",
82
+ "gawk",
83
+ "ksh",
84
+ "lua",
85
+ "node",
86
+ "perl",
87
+ "php",
88
+ "python",
89
+ "python2",
90
+ "python3",
91
+ "ruby",
92
+ "sed",
93
+ "sh",
94
+ "tclsh",
95
+ "zsh",
96
+ ]);
97
+
98
+ const HARNESS_BINS = new Set(["tlc", "tlc.cmd"]);
99
+ // why: `policy` is here for the sharpest reason of any of them — `tlc harness policy accept` exists to clear a
100
+ // tampering signal, so an agent that could reach it would make the whole integrity rail decorative. It is refused
101
+ // by a floor rule with no config switch, exactly like the rest ([/decisions/ad-030.md](/decisions/ad-030.md)).
102
+ const MUTATING_SUBCOMMANDS = new Set(["pause", "resume", "grind", "mode", "init", "gate", "policy"]);
103
+
104
+ function deny(detail: string, note: string, remedy?: string): PolicySurfaceVerdict {
105
+ return { kind: "deny", detail, note, ...(remedy ? { remedy } : {}) };
106
+ }
107
+
108
+ /**
109
+ * The route that does work, named in the refusal itself.
110
+ *
111
+ * why: reading harness state is ordinary work the bootstrap asks for by name, so a refusal on that path has to say
112
+ * how to do it rather than what not to do. The command exists so nothing has to reach into the protected path at
113
+ * all — an instruction that points at a path the floor guards is three layers disagreeing.
114
+ */
115
+ export const READ_REMEDY =
116
+ "Reading is allowed: run `tlc harness handoff` for handoff state, `tlc harness policy` for the resolved policy, or use a proven reader (cat, head, jq, grep, ls, stat, test) on the path.";
117
+
118
+ // why: the harness directory prefix is derived from the path module rather than written as a literal, so a
119
+ // change to the on-disk layout cannot leave this rule matching a path that no longer exists. The relative
120
+ // form is what appears in commands, and an absolute path contains it too.
121
+ function harnessPrefix(projectDir: string): string {
122
+ const state = normalizeSeparators(relative(projectDir, projectStateDir(projectDir)));
123
+ return state.slice(0, state.lastIndexOf("/"));
124
+ }
125
+
126
+ // hazard: the incident's command hid the path inside `python3 -c "...open('.tlc/harness/config.json','w')"`,
127
+ // which tokenizes as one word that resolves to nothing. Reasoning about the nested quoting is the
128
+ // weak-parser trap the floor refuses elsewhere, so the text is only asked whether the surface is named at
129
+ // all — and that question is only asked of verbs that were not proven to be readers.
130
+ function namesSurface(projectDir: string, segment: ShellSegment): boolean {
131
+ const text = normalizeSeparators(segment.words.map((word) => word.text).join(" "));
132
+ return text.includes(harnessPrefix(projectDir));
133
+ }
134
+
135
+ // why: the surface and the target overlap when either contains the other. Containment in the second
136
+ // direction is what catches `rm -rf .tlc/harness/state`, which removes the flags without ever naming one.
137
+ // hazard: the project root also contains the surface. Counting it would deny `find .` and `grep -r x .`,
138
+ // so the root is excluded and destruction of the whole project stays the concern of the existing rules.
139
+ function overlapsSurface(projectDir: string, resolved: string): boolean {
140
+ if (resolved === resolve(projectDir)) {
141
+ return false;
142
+ }
143
+ if (isPolicySurface(projectDir, resolved)) {
144
+ return true;
145
+ }
146
+ return [projectConfigPath(projectDir), projectStateDir(projectDir)].some(
147
+ (surface) => isInside(surface, resolved) || isInside(resolved, surface),
148
+ );
149
+ }
150
+
151
+ function referencesSurface(projectDir: string, word: ShellWord): boolean {
152
+ if (word.text === "") {
153
+ return false;
154
+ }
155
+ // hazard: an unresolved word carries a `$var` or `$(...)`, so its value is unknowable here. Matching the
156
+ // literal portion catches `> .tlc/harness/$f`; a fully computed path stays out of reach and is what the
157
+ // per-session integrity baseline exists to catch.
158
+ if (word.unresolved) {
159
+ return normalizeSeparators(word.text).includes(harnessPrefix(projectDir));
160
+ }
161
+ return overlapsSurface(projectDir, resolveTarget(projectDir, word.text));
162
+ }
163
+
164
+ // why: a redirect target is not an argument of the head verb, so argument scanning alone would allow
165
+ // `cat x > config.json` — the head verb there is a proven reader. Both the spaced and the attached forms
166
+ // have to be read, because `>f`, `>>f` and `x>f` all tokenize as a single word.
167
+ function redirectTargets(words: ShellWord[]): ShellWord[] {
168
+ const targets: ShellWord[] = [];
169
+ for (let index = 0; index < words.length; index += 1) {
170
+ const word = words[index];
171
+ // hazard: a `>` inside a quoted argument is literal text, not a redirect. Scanning quoted words denied
172
+ // commands that merely carry shell-looking data — a JSON hook payload, a fixture, a doc example.
173
+ if (!word || word.quotedStart) {
174
+ continue;
175
+ }
176
+ const match = /^(.*?)>{1,2}\|?(.*)$/s.exec(word.text);
177
+ if (!match) {
178
+ continue;
179
+ }
180
+ const attached = match[2] ?? "";
181
+ if (attached !== "") {
182
+ targets.push({ text: attached, unresolved: word.unresolved, quotedStart: false });
183
+ continue;
184
+ }
185
+ const next = words[index + 1];
186
+ if (next) {
187
+ targets.push(next);
188
+ index += 1;
189
+ }
190
+ }
191
+ return targets;
192
+ }
193
+
194
+ function harnessSubcommand(args: ShellWord[]): string | null {
195
+ const operands = args.filter((word) => !word.text.startsWith("-") && word.text !== "");
196
+ if (operands[0]?.text.toLowerCase() !== "harness") {
197
+ return null;
198
+ }
199
+ // why: `route()` defaults a missing subcommand to `status`, so bare `tlc harness` reads state and is
200
+ // not a mutation.
201
+ return (operands[1]?.text ?? "status").toLowerCase();
202
+ }
203
+
204
+ function checkSegment(projectDir: string, segment: ShellSegment): PolicySurfaceVerdict {
205
+ for (const target of redirectTargets(segment.words)) {
206
+ if (referencesSurface(projectDir, target)) {
207
+ return deny(
208
+ "a redirect in this command writes into the harness policy surface.",
209
+ "redirect into the policy surface",
210
+ );
211
+ }
212
+ }
213
+
214
+ const head = verbOf(segment.words);
215
+
216
+ if (head && HARNESS_BINS.has(head.verb)) {
217
+ const subcommand = harnessSubcommand(head.args);
218
+ if (subcommand !== null && MUTATING_SUBCOMMANDS.has(subcommand)) {
219
+ return deny(
220
+ `\`tlc harness ${subcommand}\` changes harness policy, and policy is the operator's to change.`,
221
+ `tlc harness ${subcommand}`,
222
+ );
223
+ }
224
+ }
225
+
226
+ const references = segment.words.filter((word) => referencesSurface(projectDir, word));
227
+ if (references.length === 0 && !namesSurface(projectDir, segment)) {
228
+ return ALLOW;
229
+ }
230
+
231
+ // hazard: an unreliable split means the head verb is not established, so a reader-looking head proves
232
+ // nothing. Unknown resolves to denied, as it does for the destruction rules.
233
+ if (segment.opaque) {
234
+ return deny(
235
+ "this command names the harness policy surface inside a segment this gate cannot split, so what it does to it cannot be established.",
236
+ "unprovable policy-surface access",
237
+ );
238
+ }
239
+ if (!head) {
240
+ return deny(
241
+ "the harness policy surface is named in a command with no resolvable verb.",
242
+ "policy-surface access with no verb",
243
+ );
244
+ }
245
+ // why: a proven reader is cleared before any other question, because reading the policy is ordinary work
246
+ // and the bootstrap asks for it by name. Nothing in this set can write a file on its own.
247
+ if (PROVEN_READERS.has(head.verb)) {
248
+ return ALLOW;
249
+ }
250
+ if (head.verb === "git") {
251
+ const subcommand = firstOperand(head.args)?.text.toLowerCase() ?? "";
252
+ return GIT_READERS.has(subcommand)
253
+ ? ALLOW
254
+ : deny(
255
+ `\`git ${subcommand}\` can write the working tree, so it cannot be proven to only read the harness policy surface.`,
256
+ `git ${subcommand} on the policy surface`,
257
+ READ_REMEDY,
258
+ );
259
+ }
260
+ if (references.some((word) => word.unresolved)) {
261
+ return deny(
262
+ "this command builds a harness policy path at runtime, so the file it would touch cannot be established.",
263
+ "unresolvable policy-surface path",
264
+ );
265
+ }
266
+ return deny(
267
+ `\`${head.verb}\` is not a proven reader, so this command cannot be shown to only read the harness policy surface.`,
268
+ `${head.verb} on the policy surface`,
269
+ READ_REMEDY,
270
+ );
271
+ }
272
+
273
+ // hazard: `python3 - <<PY` puts the program in the heredoc body, so the path never appears among the words.
274
+ // The body only matters when something executes it: `git commit -F -` and `cat <<EOF` receive heredocs that
275
+ // merely name this path, which is what writing about the rule looks like. A heredoc that *writes* the
276
+ // surface through a redirect or a path argument is caught by the per-segment rules instead.
277
+ function checkHeredocs(projectDir: string, heredocs: HeredocChunk[]): PolicySurfaceVerdict {
278
+ const prefix = harnessPrefix(projectDir);
279
+ for (const chunk of heredocs) {
280
+ if (!normalizeSeparators(chunk.body).includes(prefix)) {
281
+ continue;
282
+ }
283
+ // hazard: the body belongs to the verb immediately before its marker, not to the command. Asking
284
+ // whether *any* segment runs an interpreter denied `cat >> f <<EOF ... ; node --test`, where the body
285
+ // goes to cat and node is a separate command.
286
+ const owner = verbOf(tokenizeShell(chunk.prefix).at(-1)?.words ?? []);
287
+ if (owner !== null && EXECUTES_STDIN.has(owner.verb)) {
288
+ return deny(
289
+ `a heredoc fed to \`${owner.verb}\` names the harness policy surface, so the body is a program rather than a document.`,
290
+ "heredoc program naming the policy surface",
291
+ );
292
+ }
293
+ }
294
+ return ALLOW;
295
+ }
296
+
297
+ // invariant: reads no policy. It answers from paths, head verbs and the command's own text, so the file it
298
+ // protects can never influence the decision that protects it.
299
+ export function checkPolicySurface(
300
+ projectDir: string,
301
+ command: string,
302
+ segments: ShellSegment[],
303
+ ): PolicySurfaceVerdict {
304
+ for (const segment of segments) {
305
+ const verdict = checkSegment(projectDir, segment);
306
+ if (verdict.kind === "deny") {
307
+ return verdict;
308
+ }
309
+ }
310
+ return checkHeredocs(projectDir, heredocChunks(command));
311
+ }