@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,306 @@
1
+ import type { Decision } from "../../contracts/decision.ts";
2
+ import { isInside, isScratch, isSecretPath, resolveTarget } from "./floor.paths.ts";
3
+ import { checkPolicySurface } from "./floor.policy-surface.ts";
4
+ import { type ShellSegment, type ShellWord, tokenizeShell } from "./floor.tokenize.ts";
5
+ import { verbOf } from "./floor.verb.ts";
6
+
7
+ export type FloorRule =
8
+ | "machine-control"
9
+ | "secret-access"
10
+ | "unprovable-destruction"
11
+ | "history-rewrite"
12
+ | "outside-project-destruction"
13
+ | "policy-surface-write"
14
+ | "unprovable-execution";
15
+
16
+ export type FloorInput = {
17
+ projectDir: string;
18
+ toolName?: string | undefined;
19
+ filePath?: string | undefined;
20
+ command?: string | undefined;
21
+ isReadEvent?: boolean | undefined;
22
+ };
23
+
24
+ const DESTRUCTIVE_VERBS = new Set(["dd", "rm", "rmdir", "shred", "truncate"]);
25
+ const MACHINE_VERBS = new Set(["halt", "poweroff", "reboot", "shutdown"]);
26
+ const READER_VERBS = new Set(["base64", "cat", "head", "less", "more", "od", "strings", "tail", "xxd"]);
27
+ const READING_TOOLS = new Set(["Read", "Edit", "MultiEdit", "NotebookEdit"]);
28
+ const EXPANDING_VERBS = new Set([".", "eval", "source"]);
29
+ const SHELLS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]);
30
+
31
+ /**
32
+ * why: a verb whose job is to bring bytes from the network. The set is the reason the rule can be stated at all —
33
+ * a program that arrives over the wire does not exist when the decision is made
34
+ * ([/decisions/ad-074.md](/decisions/ad-074.md)).
35
+ */
36
+ const FETCH_VERBS = new Set(["aria2c", "curl", "fetch", "http", "httpie", "https", "wget"]);
37
+
38
+ /**
39
+ * The link-local services that hand out cloud credentials over HTTP.
40
+ *
41
+ * why: `secret-access` matched by path, so `~/.aws/credentials` was refused while the address returning the same
42
+ * credential was allowed. A credential is not always a file.
43
+ */
44
+ const METADATA_HOSTS = ["169.254.169.254", "169.254.170.2", "100.100.100.200", "metadata.google.internal"];
45
+
46
+ /**
47
+ * invariant: verbs that speak to the network, and nothing else. `grep -rn 169.254.169.254 .` searches this
48
+ * repository for a literal string and stays allowed — scoping to the verb is what keeps it that way.
49
+ */
50
+ const NETWORK_VERBS = new Set([...FETCH_VERBS, "nc", "ncat", "socat", "telnet", "lwp-request"]);
51
+
52
+ function namesFetcher(text: string): boolean {
53
+ return [...FETCH_VERBS].some((verb) => new RegExp(`\\b${verb}\\b`).test(text));
54
+ }
55
+
56
+ /**
57
+ * A program assembled from a network fetch and handed to a shell.
58
+ *
59
+ * hazard: measured against the floor before this existed, all four spellings were allowed — and each one hands
60
+ * the shell a payload that satisfies every other floor rule by containing nothing the gate can see. The wrapper
61
+ * deletes nothing, reads nothing and forces nothing; whatever arrives does
62
+ * ([/decisions/ad-074.md](/decisions/ad-074.md)).
63
+ */
64
+ function fetchedProgramReachesShell(command: string, segments: readonly ShellSegment[]): boolean {
65
+ // hazard: the tokenizer splits on `;`, `|` and `&` alike, so carrying a flag across segments treated
66
+ // `curl --version && bash ./scripts/deploy.sh` as a download piped to a shell. Requiring a literal pipe keeps
67
+ // every real `curl … | bash` and drops a sequence that merely mentions a fetcher — a false positive is
68
+ // expensive in a rule with no switch ([/decisions/ad-034.md](/decisions/ad-034.md)).
69
+ const piped = command.includes("|");
70
+ let upstreamFetches = false;
71
+ for (const segment of segments) {
72
+ const head = verbOf(segment.words);
73
+ if (!head) {
74
+ continue;
75
+ }
76
+ const { verb, args } = head;
77
+
78
+ // 1. a pipeline whose upstream fetched and whose downstream is a shell
79
+ if (piped && upstreamFetches && SHELLS.has(verb)) {
80
+ return true;
81
+ }
82
+
83
+ if (SHELLS.has(verb) || EXPANDING_VERBS.has(verb)) {
84
+ for (const word of args) {
85
+ // 2. process substitution: `bash <(curl …)`
86
+ // 3. and 4. an unresolved word that names a fetcher — `sh -c "$(curl …)"`, `eval "$(curl …)"`
87
+ const substitution = word.text.includes("<(") || word.unresolved;
88
+ if (substitution && namesFetcher(word.text)) {
89
+ return true;
90
+ }
91
+ }
92
+ }
93
+
94
+ upstreamFetches = FETCH_VERBS.has(verb);
95
+ }
96
+ return false;
97
+ }
98
+
99
+ // why: `bash script.sh` runs a file this gate cannot see, which is a coverage limit rather than evasion.
100
+ // `bash -c "..."` carries the command inline, which is the case worth refusing.
101
+ function buildsCommandAtRuntime(verb: string, args: ShellWord[]): boolean {
102
+ return EXPANDING_VERBS.has(verb) || (SHELLS.has(verb) && args.some((word) => word.text === "-c"));
103
+ }
104
+
105
+ function reason(rule: FloorRule, detail: string): string {
106
+ return [
107
+ `FLOOR: ${detail}`,
108
+ "This is a floor rule — it has no config switch, because a limit an agent can turn off is not a limit.",
109
+ "Restate what you need and let the operator decide; do not work around this.",
110
+ `rule=${rule}`,
111
+ ].join("\n");
112
+ }
113
+
114
+ // why: the rule was already written into the reason prose as `rule=<name>`. Carrying it structurally as well is
115
+ // what lets a refusal be counted and attributed without parsing English
116
+ // ([/decisions/ad-027.md](/decisions/ad-027.md)).
117
+ function denial(rule: FloorRule, detail: string, note: string): Decision {
118
+ return {
119
+ kind: "deny",
120
+ reason: reason(rule, detail),
121
+ userNote: `Floor rule ${rule}: ${note}`,
122
+ rule,
123
+ };
124
+ }
125
+
126
+ function isMkfs(verb: string): boolean {
127
+ return verb === "mkfs" || verb.startsWith("mkfs.");
128
+ }
129
+
130
+ function isDangerousVerb(token: string): boolean {
131
+ const verb = token.split("/").pop() ?? token;
132
+ return DESTRUCTIVE_VERBS.has(verb) || MACHINE_VERBS.has(verb) || isMkfs(verb);
133
+ }
134
+
135
+ function hidesDestructiveVerb(segment: ShellSegment): boolean {
136
+ return segment.words.some((word) => word.text.split(/\s+/).some(isDangerousVerb));
137
+ }
138
+
139
+ function pathArgs(args: ShellWord[]): ShellWord[] {
140
+ return args.filter((word) => !word.text.startsWith("-") && word.text !== "");
141
+ }
142
+
143
+ function checkShell(input: FloorInput): Decision {
144
+ const command = input.command;
145
+ if (!command) {
146
+ return { kind: "allow" };
147
+ }
148
+
149
+ const segments = tokenizeShell(command);
150
+
151
+ // invariant: asked before the rest. A fetched program satisfies every other rule by containing nothing this
152
+ // gate can read, so checking the wrapper first and the payload never is the order that let it through.
153
+ if (fetchedProgramReachesShell(command, segments)) {
154
+ return denial(
155
+ "unprovable-execution",
156
+ "This runs a program fetched over the network, which does not exist for this gate to check. Download it to a file, read it, then run that file.",
157
+ "fetched program piped to a shell",
158
+ );
159
+ }
160
+
161
+ for (const segment of segments) {
162
+ const head = verbOf(segment.words);
163
+ if (!head) {
164
+ continue;
165
+ }
166
+ const { verb, args } = head;
167
+
168
+ // hazard: `eval "rm -rf /"` and `bash -c "rm -rf /"` build their command at runtime, so the head
169
+ // word does not describe what will run. Reasoning about the nested quoting is the weak-parser
170
+ // trap — refuse the segment instead of interpreting it. Scanning words is only sound here: doing
171
+ // it for any opaque segment flags an `rm` quoted as data somewhere in a long script.
172
+ if (buildsCommandAtRuntime(verb, args) && hidesDestructiveVerb(segment)) {
173
+ return denial(
174
+ "unprovable-destruction",
175
+ "A destructive verb appears inside a command this gate cannot expand, so its target cannot be established. Run it directly with a literal path instead.",
176
+ "hidden destructive verb",
177
+ );
178
+ }
179
+
180
+ if (MACHINE_VERBS.has(verb)) {
181
+ return denial("machine-control", `\`${verb}\` controls the machine, not the project.`, verb);
182
+ }
183
+
184
+ if (verb === "git" && args.some((word) => word.text === "push")) {
185
+ const forced = args.some((word) => word.text === "--force" || word.text === "-f");
186
+ if (forced) {
187
+ return denial(
188
+ "history-rewrite",
189
+ "`git push --force` discards remote commits that are not in your history. Use --force-with-lease, which refuses when the remote moved.",
190
+ "force push",
191
+ );
192
+ }
193
+ }
194
+
195
+ const destructive = DESTRUCTIVE_VERBS.has(verb) || isMkfs(verb);
196
+ if (!destructive) {
197
+ continue;
198
+ }
199
+
200
+ const targets = pathArgs(args);
201
+
202
+ // hazard: an opaque segment or an unresolved word means the target is unknown. The floor must
203
+ // prove the target is safe, not prove it is dangerous, so unknown resolves to denied.
204
+ if (segment.opaque || targets.some((word) => word.unresolved) || targets.length === 0) {
205
+ return denial(
206
+ "unprovable-destruction",
207
+ `\`${verb}\` was called with a target this gate cannot resolve, so its safety cannot be established. Re-run it with a literal path inside the project.`,
208
+ `unresolvable ${verb}`,
209
+ );
210
+ }
211
+
212
+ for (const word of targets) {
213
+ const resolved = resolveTarget(input.projectDir, word.text);
214
+ if (!isInside(input.projectDir, resolved) && !isScratch(resolved)) {
215
+ return denial(
216
+ "outside-project-destruction",
217
+ `\`${verb}\` targets ${resolved}, which is outside the project and outside scratch space.`,
218
+ `${verb} outside project`,
219
+ );
220
+ }
221
+ }
222
+ }
223
+
224
+ // hazard: the guard that used to defend this surface keyed off tool names, so a single shell line went
225
+ // around it. The rule belongs here, where the decision is made before any policy is read.
226
+ const surface = checkPolicySurface(input.projectDir, command, segments);
227
+ if (surface.kind === "deny") {
228
+ // invariant: the remedy comes from the branch that denied, so a read refusal names how to read and a write
229
+ // refusal names who may write. One fixed tail on both handed write advice to an agent trying to read
230
+ // ([/decisions/ad-047.md](/decisions/ad-047.md)).
231
+ const remedy =
232
+ surface.remedy ??
233
+ "Set a gate command with `tlc harness gate test-command` or `gate lint-command`, and run policy changes from your own terminal rather than from inside this session.";
234
+ return denial("policy-surface-write", `${surface.detail} ${remedy}`, surface.note);
235
+ }
236
+
237
+ return checkShellSecrets(segments, input.projectDir);
238
+ }
239
+
240
+ function checkShellSecrets(segments: ShellSegment[], projectDir: string): Decision {
241
+ for (const segment of segments) {
242
+ const head = verbOf(segment.words);
243
+ if (!head) {
244
+ continue;
245
+ }
246
+ if (NETWORK_VERBS.has(head.verb)) {
247
+ const target = head.args.map((word) => word.text).join(" ");
248
+ const endpoint = METADATA_HOSTS.find((host) => target.includes(host));
249
+ if (endpoint !== undefined) {
250
+ return denial(
251
+ "secret-access",
252
+ `${endpoint} is the instance metadata service, and \`${head.verb}\` would copy the credentials it returns into the transcript.`,
253
+ `read of ${endpoint}`,
254
+ );
255
+ }
256
+ }
257
+ if (!READER_VERBS.has(head.verb)) {
258
+ continue;
259
+ }
260
+ for (const word of pathArgs(head.args)) {
261
+ if (word.unresolved) {
262
+ continue;
263
+ }
264
+ const resolved = resolveTarget(projectDir, word.text);
265
+ if (isSecretPath(resolved)) {
266
+ return denial(
267
+ "secret-access",
268
+ `\`${head.verb}\` would read ${resolved} into the transcript. Credentials do not belong in an agent's context.`,
269
+ `read of ${resolved}`,
270
+ );
271
+ }
272
+ }
273
+ }
274
+ return { kind: "allow" };
275
+ }
276
+
277
+ function checkFile(input: FloorInput): Decision {
278
+ const filePath = input.filePath;
279
+ if (!filePath) {
280
+ return { kind: "allow" };
281
+ }
282
+ const reads =
283
+ input.isReadEvent === true || (input.toolName !== undefined && READING_TOOLS.has(input.toolName));
284
+ if (!reads) {
285
+ return { kind: "allow" };
286
+ }
287
+ const resolved = resolveTarget(input.projectDir, filePath);
288
+ if (!isSecretPath(resolved)) {
289
+ return { kind: "allow" };
290
+ }
291
+ return denial(
292
+ "secret-access",
293
+ `${resolved} holds credentials, and reading it would copy them into the transcript.`,
294
+ `read of ${resolved}`,
295
+ );
296
+ }
297
+
298
+ // invariant: this function takes no policy. Adding a config parameter here would turn the floor into
299
+ // a guardrail, which is the one thing it must not be.
300
+ export function evaluateFloor(input: FloorInput): Decision {
301
+ const file = checkFile(input);
302
+ if (file.kind !== "allow") {
303
+ return file;
304
+ }
305
+ return checkShell(input);
306
+ }
@@ -0,0 +1,179 @@
1
+ export type ShellWord = {
2
+ text: string;
3
+ /** True when the word carries a `$var`, `$(...)` or backtick — its value is not knowable here. */
4
+ unresolved: boolean;
5
+ /**
6
+ * True when the word opened with a quote, so every operator inside it is literal text. A caller looking
7
+ * for redirects must skip these: `'{"cmd":"x > cfg"}'` is one quoted argument, not a redirect.
8
+ * Only the opening matters — `>"$f"` starts unquoted and is a real redirect.
9
+ */
10
+ quotedStart: boolean;
11
+ };
12
+
13
+ export type ShellSegment = {
14
+ words: ShellWord[];
15
+ /**
16
+ * True when the segment could not be split with confidence. Which *verbs* defeat inspection is a
17
+ * policy question and belongs to the caller — this only reports failures of splitting.
18
+ */
19
+ opaque: boolean;
20
+ };
21
+
22
+ const SEPARATORS = new Set([";", "|", "&", "\n"]);
23
+ const ESCAPABLE = new Set([" ", "\t", '"', "'", "$", "`", "\\", ";", "|", "&", "(", ")"]);
24
+
25
+ function isExpansion(text: string): boolean {
26
+ return text.includes("$") || text.includes("`");
27
+ }
28
+
29
+ // hazard: a heredoc body is data being written, not a command list. Tokenizing it invents segments whose
30
+ // head verb never runs, and its quotes make the whole command look unbalanced. It is separated here rather
31
+ // than discarded, because `python3 - <<PY` makes that body the program — a caller that never sees it cannot
32
+ // tell a document from a script.
33
+ /**
34
+ * A heredoc body, paired with the command text that preceded its `<<TAG` marker. The prefix is what
35
+ * identifies the verb the body is fed to — without it, a body can only be attributed to the command as a
36
+ * whole, which mistakes `cat <<EOF ... ; node x` for a program handed to node.
37
+ */
38
+ export type HeredocChunk = { body: string; prefix: string };
39
+
40
+ export function splitHeredocs(command: string): { stripped: string; heredocs: HeredocChunk[] } {
41
+ const heredoc = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/;
42
+ const heredocs: HeredocChunk[] = [];
43
+ let rest = command;
44
+ let stripped = "";
45
+ for (;;) {
46
+ const match = heredoc.exec(rest);
47
+ if (!match) {
48
+ stripped += rest;
49
+ break;
50
+ }
51
+ const bodyStart = rest.indexOf("\n", match.index + match[0].length);
52
+ const prefix = stripped + rest.slice(0, match.index);
53
+ stripped += rest.slice(0, match.index);
54
+ if (bodyStart === -1) {
55
+ break;
56
+ }
57
+ const terminator = new RegExp(`^\\s*${match[2] as string}\\s*$`, "m");
58
+ const body = rest.slice(bodyStart + 1);
59
+ const end = terminator.exec(body);
60
+ if (!end) {
61
+ heredocs.push({ body, prefix });
62
+ break;
63
+ }
64
+ heredocs.push({ body: body.slice(0, end.index), prefix });
65
+ rest = body.slice(end.index + end[0].length);
66
+ }
67
+ return { stripped, heredocs };
68
+ }
69
+
70
+ export function heredocChunks(command: string): HeredocChunk[] {
71
+ return splitHeredocs(command).heredocs;
72
+ }
73
+
74
+ // invariant: this splits words, it does not evaluate them. Anything it cannot split with confidence
75
+ // is reported as opaque so callers can refuse rather than guess — a wrong split must never read as
76
+ // a safe command.
77
+ export function tokenizeShell(command: string): ShellSegment[] {
78
+ const segments: ShellSegment[] = [];
79
+ let words: ShellWord[] = [];
80
+ let current = "";
81
+ let currentHadQuote = false;
82
+ let currentStartedQuoted = false;
83
+ let quote: '"' | "'" | null = null;
84
+ let unbalanced = false;
85
+ let depth = 0;
86
+
87
+ function pushWord(): void {
88
+ if (current !== "" || currentHadQuote) {
89
+ words.push({
90
+ text: current,
91
+ unresolved: isExpansion(current),
92
+ quotedStart: currentStartedQuoted,
93
+ });
94
+ }
95
+ current = "";
96
+ currentHadQuote = false;
97
+ currentStartedQuoted = false;
98
+ }
99
+
100
+ function pushSegment(): void {
101
+ pushWord();
102
+ if (words.length > 0) {
103
+ segments.push({ words, opaque: unbalanced });
104
+ }
105
+ words = [];
106
+ }
107
+
108
+ const { stripped } = splitHeredocs(command);
109
+
110
+ for (let index = 0; index < stripped.length; index += 1) {
111
+ const char = stripped[index] as string;
112
+
113
+ if (quote !== null) {
114
+ if (char === quote) {
115
+ quote = null;
116
+ } else {
117
+ current += char;
118
+ }
119
+ continue;
120
+ }
121
+
122
+ // hazard: treating every backslash as an escape turned `C:\Users\me\.ssh\id_rsa` into
123
+ // `C:Usersme.sshid_rsa`, so no Windows path resolved and the secret rule silently passed it.
124
+ // A backslash only escapes what actually needs escaping; anything else is part of the path.
125
+ if (char === "\\") {
126
+ const next = stripped[index + 1];
127
+ if (next !== undefined && ESCAPABLE.has(next)) {
128
+ current += next;
129
+ index += 1;
130
+ } else {
131
+ current += char;
132
+ }
133
+ continue;
134
+ }
135
+
136
+ if (char === '"' || char === "'") {
137
+ quote = char;
138
+ if (current === "" && !currentHadQuote) {
139
+ currentStartedQuoted = true;
140
+ }
141
+ currentHadQuote = true;
142
+ continue;
143
+ }
144
+
145
+ // hazard: a separator inside $( ) or ${ } belongs to the substitution, not to the command list.
146
+ // Splitting there would hand a fragment to the rules and lose the real verb.
147
+ if (char === "$" && (stripped[index + 1] === "(" || stripped[index + 1] === "{")) {
148
+ depth += 1;
149
+ current += char;
150
+ continue;
151
+ }
152
+ if (depth > 0 && (char === ")" || char === "}")) {
153
+ depth -= 1;
154
+ current += char;
155
+ continue;
156
+ }
157
+
158
+ if (depth === 0 && SEPARATORS.has(char)) {
159
+ pushSegment();
160
+ continue;
161
+ }
162
+
163
+ if (depth === 0 && (char === " " || char === "\t")) {
164
+ pushWord();
165
+ continue;
166
+ }
167
+
168
+ current += char;
169
+ }
170
+
171
+ if (quote !== null || depth > 0) {
172
+ unbalanced = true;
173
+ }
174
+ pushSegment();
175
+
176
+ // why: an unbalanced quote is only detectable at the end, so segments split before it were judged
177
+ // under the wrong assumption. Re-mark the whole list rather than trust those splits.
178
+ return unbalanced ? segments.map((segment) => ({ ...segment, opaque: true })) : segments;
179
+ }
@@ -0,0 +1,28 @@
1
+ import type { ShellWord } from "./floor.tokenize.ts";
2
+
3
+ export const WRAPPERS = new Set(["command", "doas", "env", "nice", "nohup", "sudo", "time", "xargs"]);
4
+
5
+ export type SegmentHead = { verb: string; args: ShellWord[] };
6
+
7
+ // why: `env FOO=bar cmd` and `sudo -n cmd` both delay the real verb; flags and assignments are skipped.
8
+ export function verbOf(words: ShellWord[]): SegmentHead | null {
9
+ let index = 0;
10
+ while (index < words.length) {
11
+ const word = words[index];
12
+ if (!word) {
13
+ return null;
14
+ }
15
+ if (WRAPPERS.has(word.text) || word.text.startsWith("-") || word.text.includes("=")) {
16
+ index += 1;
17
+ continue;
18
+ }
19
+ return { verb: word.text.split("/").pop() ?? word.text, args: words.slice(index + 1) };
20
+ }
21
+ return null;
22
+ }
23
+
24
+ // why: two floor rules need the head verb, and a second copy of the wrapper list would drift from this one
25
+ // the first time a wrapper is added.
26
+ export function firstOperand(args: ShellWord[]): ShellWord | null {
27
+ return args.find((word) => !word.text.startsWith("-") && word.text !== "") ?? null;
28
+ }
@@ -0,0 +1 @@
1
+ export { evaluateFloor, type FloorInput, type FloorRule } from "./floor.service.ts";
@@ -0,0 +1,157 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { setProjectScopedEnv } from "../../platform/env-scope.ts";
5
+ import { projectStateDir } from "../../platform/paths.ts";
6
+ import { findingsFromLines } from "./gate.findings.ts";
7
+ import { GATE_SCHEMA, type GateFinding, type LastGateArtifact } from "./gate.types.ts";
8
+
9
+ export const OUTPUT_TAIL_MAX = 8000;
10
+ export const FINDINGS_MAX = 8;
11
+
12
+ const FAIL_HINT =
13
+ /(?:\bFAIL(?:ED)?\b|\bERROR\b|Error:|error\[|AssertionError|\bpanic:|✗|×|✕|✖|failures?\s*[:=]\s*[1-9])/i;
14
+
15
+ export function lastGatePath(root: string): string {
16
+ return join(projectStateDir(root), "last-gate.json");
17
+ }
18
+
19
+ export function gateReportPath(root: string): string {
20
+ return join(projectStateDir(root), "gate-report.json");
21
+ }
22
+
23
+ export function trimOutputTail(combined: string, max = OUTPUT_TAIL_MAX): string {
24
+ const text = combined.trim();
25
+ if (!text) {
26
+ return "";
27
+ }
28
+ return text.length <= max ? text : text.slice(-max);
29
+ }
30
+
31
+ export function clearGateReport(root: string): void {
32
+ const path = gateReportPath(root);
33
+ if (existsSync(path)) {
34
+ try {
35
+ unlinkSync(path);
36
+ } catch {}
37
+ }
38
+ }
39
+
40
+ function readJson<T>(path: string): T | null {
41
+ if (!existsSync(path)) {
42
+ return null;
43
+ }
44
+ try {
45
+ return JSON.parse(readFileSync(path, "utf8")) as T;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function readReportFindings(reportPath: string): GateFinding[] | null {
52
+ const raw = readJson<unknown>(reportPath);
53
+ if (!raw || typeof raw !== "object") {
54
+ return null;
55
+ }
56
+ const findings = (raw as { findings?: unknown }).findings;
57
+ if (!Array.isArray(findings)) {
58
+ return null;
59
+ }
60
+ const out: GateFinding[] = [];
61
+ for (const item of findings) {
62
+ if (!item || typeof item !== "object") {
63
+ continue;
64
+ }
65
+ const summary = (item as { summary?: unknown }).summary;
66
+ if (typeof summary !== "string" || !summary.trim()) {
67
+ continue;
68
+ }
69
+ const detail = (item as { detail?: unknown }).detail;
70
+ const id = (item as { id?: unknown }).id;
71
+ out.push({
72
+ summary: summary.trim().slice(0, 200),
73
+ detail: typeof detail === "string" ? detail.slice(0, 500) : undefined,
74
+ id: typeof id === "string" ? id : undefined,
75
+ });
76
+ if (out.length >= FINDINGS_MAX) {
77
+ break;
78
+ }
79
+ }
80
+ return out.length > 0 ? out : null;
81
+ }
82
+
83
+ // hazard: this used to emit one finding per matched line, so a single failing test arrived as three problems
84
+ // to fix — the assertion header, the test name and the tally `1 fail`. The consumer instructs an agent to fix
85
+ // every item, so grouping is not cosmetic. Matching stays as permissive as before; only what happens to the
86
+ // matched lines changed.
87
+ export function extractFindingsFromOutput(
88
+ outputTail: string,
89
+ exitCode: number,
90
+ max = FINDINGS_MAX,
91
+ ): GateFinding[] {
92
+ const lines = outputTail
93
+ .split("\n")
94
+ .map((line) => line.trim())
95
+ .filter((line) => line.length > 0 && !line.startsWith(">"));
96
+ const hits = lines.filter((line) => FAIL_HINT.test(line));
97
+ const picked = hits.length > 0 ? hits : lines.slice(-max);
98
+ return findingsFromLines(picked, exitCode, max);
99
+ }
100
+
101
+ export function writeLastGate(args: {
102
+ root: string;
103
+ gate: string;
104
+ exitCode: number;
105
+ command: string[];
106
+ files: string[];
107
+ durationMs: number;
108
+ output: string;
109
+ reportPath?: string;
110
+ inputsHash?: string;
111
+ }): LastGateArtifact {
112
+ const outputTail = trimOutputTail(args.output);
113
+ const fromReport = args.reportPath ? readReportFindings(args.reportPath) : null;
114
+ const emptyOutput = !outputTail || outputTail === "(no output captured)";
115
+ const findings =
116
+ fromReport ??
117
+ (args.exitCode === 0
118
+ ? []
119
+ : emptyOutput
120
+ ? [{ summary: `gate exited with code ${args.exitCode}` }]
121
+ : extractFindingsFromOutput(outputTail, args.exitCode));
122
+ const artifact: LastGateArtifact = {
123
+ schema: GATE_SCHEMA,
124
+ gate: args.gate,
125
+ exitCode: args.exitCode,
126
+ passed: args.exitCode === 0,
127
+ command: args.command,
128
+ files: [...args.files],
129
+ durationMs: args.durationMs,
130
+ ts: new Date().toISOString(),
131
+ outputTail,
132
+ findings,
133
+ // invariant: read here rather than passed in, so every gate records it and a gate added later cannot forget.
134
+ // Core never spells a variable name — `platform/env-scope.ts` owns the list, because `check-boundaries`
135
+ // forbids a vendor identifier under `core/` ([/decisions/ad-060.md](/decisions/ad-060.md)).
136
+ scopedEnv: setProjectScopedEnv(),
137
+ ...(args.inputsHash ? { inputsHash: args.inputsHash } : {}),
138
+ };
139
+ const path = lastGatePath(args.root);
140
+ mkdirSync(dirname(path), { recursive: true });
141
+ writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
142
+ return artifact;
143
+ }
144
+
145
+ export function readLastGate(root: string): LastGateArtifact | null {
146
+ return readJson<LastGateArtifact>(lastGatePath(root));
147
+ }
148
+
149
+ export function computeGateFingerprint(artifact: LastGateArtifact): string {
150
+ const raw = JSON.stringify({
151
+ gate: artifact.gate,
152
+ exitCode: artifact.exitCode,
153
+ files: [...artifact.files].sort(),
154
+ findings: artifact.findings.map((f) => f.summary).sort(),
155
+ });
156
+ return createHash("sha256").update(raw).digest("hex").slice(0, 16);
157
+ }