@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.
- package/CHANGELOG.md +95 -0
- package/LICENSE +84 -0
- package/NOTICE +9 -0
- package/README.md +480 -0
- package/bin/tlc +10 -0
- package/bin/tlc-build +80 -0
- package/bin/tlc-cli.ts +1509 -0
- package/bin/tlc-exec +10 -0
- package/bin/tlc-exec.cmd +4 -0
- package/bin/tlc-exec.d.mts +60 -0
- package/bin/tlc-exec.mjs +248 -0
- package/bin/tlc.cmd +4 -0
- package/bin/tlc.mjs +7 -0
- package/bin/write-user-hooks.d.mts +40 -0
- package/bin/write-user-hooks.mjs +123 -0
- package/capabilities/catalog.json +418 -0
- package/config.example.json +51 -0
- package/dist/compact-before.mjs +7839 -0
- package/dist/doctor.mjs +8263 -0
- package/dist/help-topic.mjs +82 -0
- package/dist/init-project.mjs +993 -0
- package/dist/install-runtime.mjs +969 -0
- package/dist/lessons-cli.mjs +7258 -0
- package/dist/obs-cli.mjs +7025 -0
- package/dist/price-lookup.mjs +221 -0
- package/dist/prompt-submit.mjs +7860 -0
- package/dist/refresh-model-prices.mjs +158 -0
- package/dist/response-after.mjs +7879 -0
- package/dist/run.mjs +7830 -0
- package/dist/session-end.mjs +7937 -0
- package/dist/session-start.mjs +8056 -0
- package/dist/shim.mjs +52 -0
- package/dist/stop.mjs +8494 -0
- package/dist/subagent-start.mjs +7861 -0
- package/dist/subagent-stop.mjs +7851 -0
- package/dist/support.mjs +7045 -0
- package/dist/tlc-cli.mjs +8063 -0
- package/dist/tool-after.mjs +8113 -0
- package/dist/tool-before.mjs +7985 -0
- package/dist/tool-failure.mjs +7850 -0
- package/dist/uninstall-runtime.mjs +1307 -0
- package/docs/architecture.md +270 -0
- package/docs/concepts.md +562 -0
- package/docs/coverage.md +55 -0
- package/docs/decisions/ad-001.md +32 -0
- package/docs/decisions/ad-002.md +51 -0
- package/docs/decisions/ad-003.md +30 -0
- package/docs/decisions/ad-004.md +37 -0
- package/docs/decisions/ad-005.md +36 -0
- package/docs/decisions/ad-006.md +49 -0
- package/docs/decisions/ad-007.md +36 -0
- package/docs/decisions/ad-008.md +54 -0
- package/docs/decisions/ad-009.md +61 -0
- package/docs/decisions/ad-010.md +45 -0
- package/docs/decisions/ad-011.md +59 -0
- package/docs/decisions/ad-012.md +71 -0
- package/docs/decisions/ad-013.md +87 -0
- package/docs/decisions/ad-014.md +56 -0
- package/docs/decisions/ad-015.md +33 -0
- package/docs/decisions/ad-016.md +98 -0
- package/docs/decisions/ad-017.md +65 -0
- package/docs/decisions/ad-018.md +77 -0
- package/docs/decisions/ad-019.md +75 -0
- package/docs/decisions/ad-020.md +88 -0
- package/docs/decisions/ad-021.md +57 -0
- package/docs/decisions/ad-022.md +120 -0
- package/docs/decisions/ad-023.md +87 -0
- package/docs/decisions/ad-024.md +85 -0
- package/docs/decisions/ad-025.md +92 -0
- package/docs/decisions/ad-026.md +131 -0
- package/docs/decisions/ad-027.md +90 -0
- package/docs/decisions/ad-028.md +90 -0
- package/docs/decisions/ad-029.md +73 -0
- package/docs/decisions/ad-030.md +97 -0
- package/docs/decisions/ad-031.md +94 -0
- package/docs/decisions/ad-032.md +83 -0
- package/docs/decisions/ad-033.md +89 -0
- package/docs/decisions/ad-034.md +86 -0
- package/docs/decisions/ad-035.md +86 -0
- package/docs/decisions/ad-036.md +68 -0
- package/docs/decisions/ad-037.md +47 -0
- package/docs/decisions/ad-038.md +52 -0
- package/docs/decisions/ad-039.md +69 -0
- package/docs/decisions/ad-040.md +89 -0
- package/docs/decisions/ad-041.md +98 -0
- package/docs/decisions/ad-042.md +82 -0
- package/docs/decisions/ad-043.md +79 -0
- package/docs/decisions/ad-044.md +61 -0
- package/docs/decisions/ad-045.md +94 -0
- package/docs/decisions/ad-046.md +111 -0
- package/docs/decisions/ad-047.md +96 -0
- package/docs/decisions/ad-048.md +85 -0
- package/docs/decisions/ad-049.md +66 -0
- package/docs/decisions/ad-050.md +94 -0
- package/docs/decisions/ad-051.md +69 -0
- package/docs/decisions/ad-052.md +69 -0
- package/docs/decisions/ad-053.md +78 -0
- package/docs/decisions/ad-054.md +98 -0
- package/docs/decisions/ad-055.md +74 -0
- package/docs/decisions/ad-056.md +85 -0
- package/docs/decisions/ad-057.md +68 -0
- package/docs/decisions/ad-058.md +97 -0
- package/docs/decisions/ad-059.md +82 -0
- package/docs/decisions/ad-060.md +75 -0
- package/docs/decisions/ad-061.md +68 -0
- package/docs/decisions/ad-062.md +72 -0
- package/docs/decisions/ad-063.md +84 -0
- package/docs/decisions/ad-064.md +79 -0
- package/docs/decisions/ad-065.md +81 -0
- package/docs/decisions/ad-066.md +111 -0
- package/docs/decisions/ad-067.md +64 -0
- package/docs/decisions/ad-068.md +79 -0
- package/docs/decisions/ad-069.md +74 -0
- package/docs/decisions/ad-070.md +86 -0
- package/docs/decisions/ad-071.md +93 -0
- package/docs/decisions/ad-072.md +82 -0
- package/docs/decisions/ad-073.md +102 -0
- package/docs/decisions/ad-074.md +91 -0
- package/docs/decisions/ad-075.md +79 -0
- package/docs/decisions/ad-076.md +102 -0
- package/docs/decisions/ad-077.md +94 -0
- package/docs/decisions/ad-078.md +84 -0
- package/docs/decisions/ad-079.md +73 -0
- package/docs/decisions/ad-080.md +86 -0
- package/docs/decisions/ad-081.md +70 -0
- package/docs/decisions/ad-082.md +79 -0
- package/docs/decisions/ad-083.md +88 -0
- package/docs/decisions/index.md +111 -0
- package/docs/diagnose.md +248 -0
- package/docs/index.md +52 -0
- package/docs/init.md +101 -0
- package/docs/lessons.md +294 -0
- package/docs/log.md +139 -0
- package/docs/measure.md +178 -0
- package/docs/providers/claude-code.md +118 -0
- package/docs/providers/cursor.md +110 -0
- package/docs/providers/index.md +71 -0
- package/docs/troubleshooting.md +138 -0
- package/model-aliases.json +12 -0
- package/model-prices.cursor.json +410 -0
- package/model-prices.json +1 -0
- package/package.json +57 -0
- package/skills/harness-init/SKILL.md +241 -0
- package/skills/harness-init/evals/trigger_evals.json +86 -0
- package/skills/harness-init/references/capabilities.md +113 -0
- package/src/contracts/capabilities.ts +30 -0
- package/src/contracts/decision.ts +21 -0
- package/src/contracts/effort.ts +15 -0
- package/src/contracts/harness-event.ts +87 -0
- package/src/contracts/index.ts +6 -0
- package/src/contracts/policy-defaults.ts +14 -0
- package/src/contracts/wiring.ts +20 -0
- package/src/core/attest/attest.service.ts +0 -0
- package/src/core/capability/capability.service.ts +62 -0
- package/src/core/capability/capability.store.ts +51 -0
- package/src/core/capability/capability.types.ts +56 -0
- package/src/core/comment-policy/comment-policy.service.ts +307 -0
- package/src/core/comment-policy/comment-policy.types.ts +6 -0
- package/src/core/comment-policy/comment-resolvability.ts +134 -0
- package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
- package/src/core/comment-policy/comment-syntax.store.ts +65 -0
- package/src/core/comment-policy/comment-syntax.types.ts +24 -0
- package/src/core/core.facade.ts +461 -0
- package/src/core/duplication/duplication.service.ts +219 -0
- package/src/core/floor/floor.catalog.ts +54 -0
- package/src/core/floor/floor.paths.ts +90 -0
- package/src/core/floor/floor.policy-surface.ts +311 -0
- package/src/core/floor/floor.service.ts +306 -0
- package/src/core/floor/floor.tokenize.ts +179 -0
- package/src/core/floor/floor.verb.ts +28 -0
- package/src/core/floor/index.ts +1 -0
- package/src/core/gate/gate.artifact.ts +157 -0
- package/src/core/gate/gate.command.ts +130 -0
- package/src/core/gate/gate.findings.ts +201 -0
- package/src/core/gate/gate.inputs.ts +0 -0
- package/src/core/gate/gate.lock.ts +283 -0
- package/src/core/gate/gate.service.ts +28 -0
- package/src/core/gate/gate.types.ts +67 -0
- package/src/core/handoff/handoff.service.ts +43 -0
- package/src/core/handoff/handoff.store.ts +59 -0
- package/src/core/handoff/handoff.types.ts +89 -0
- package/src/core/index.ts +52 -0
- package/src/core/integrity/state-seal.ts +81 -0
- package/src/core/lesson/lesson.authored.ts +96 -0
- package/src/core/lesson/lesson.credit.ts +51 -0
- package/src/core/lesson/lesson.garden.ts +283 -0
- package/src/core/lesson/lesson.link.ts +93 -0
- package/src/core/lesson/lesson.score.ts +61 -0
- package/src/core/lesson/lesson.select.ts +209 -0
- package/src/core/lesson/lesson.service.ts +110 -0
- package/src/core/lesson/lesson.store.ts +317 -0
- package/src/core/lesson/lesson.sync.ts +70 -0
- package/src/core/lesson/lesson.types.ts +92 -0
- package/src/core/lesson/lesson.validity.ts +44 -0
- package/src/core/observability/observability.report.ts +326 -0
- package/src/core/observability/observability.service.ts +328 -0
- package/src/core/observability/observability.store.ts +269 -0
- package/src/core/observability/observability.types.ts +193 -0
- package/src/core/observability/observability.why.ts +206 -0
- package/src/core/observe/observe.service.ts +76 -0
- package/src/core/plan/plan.detect.ts +47 -0
- package/src/core/plan/plan.service.ts +81 -0
- package/src/core/plan/plan.types.ts +25 -0
- package/src/core/policy/policy.defaults.ts +103 -0
- package/src/core/policy/policy.guard.ts +34 -0
- package/src/core/policy/policy.integrity.ts +238 -0
- package/src/core/policy/policy.loader.ts +118 -0
- package/src/core/policy/policy.operator.ts +82 -0
- package/src/core/policy/policy.posture.ts +79 -0
- package/src/core/policy/policy.rails.ts +45 -0
- package/src/core/policy/policy.types.ts +157 -0
- package/src/core/presence/presence.service.ts +111 -0
- package/src/core/presence/presence.store.ts +59 -0
- package/src/core/presence/presence.types.ts +9 -0
- package/src/core/release/release.decisions.ts +147 -0
- package/src/core/release/release.seen.ts +38 -0
- package/src/core/shell-policy/shell-policy.service.ts +215 -0
- package/src/core/shell-policy/shell-policy.stall.ts +57 -0
- package/src/core/shell-policy/shell-policy.types.ts +26 -0
- package/src/core/ship/ship.ledger.ts +76 -0
- package/src/core/ship/ship.service.ts +137 -0
- package/src/core/ship/ship.types.ts +19 -0
- package/src/core/stagnation/stagnation.resolution.ts +87 -0
- package/src/core/stagnation/stagnation.service.ts +20 -0
- package/src/core/stagnation/stagnation.store.ts +49 -0
- package/src/core/stagnation/stagnation.types.ts +12 -0
- package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
- package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
- package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
- package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
- package/src/core/supply-chain/supply-chain.service.ts +217 -0
- package/src/core/turn/turn.activity.ts +94 -0
- package/src/core/turn/turn.autopilot.ts +131 -0
- package/src/core/turn/turn.failure-signals.ts +193 -0
- package/src/core/turn/turn.loop-counter.ts +71 -0
- package/src/core/turn/turn.types.ts +14 -0
- package/src/core/untrusted/untrusted.detect.ts +53 -0
- package/src/core/untrusted/untrusted.recall.ts +87 -0
- package/src/core/untrusted/untrusted.service.ts +129 -0
- package/src/core/untrusted/untrusted.store.ts +75 -0
- package/src/core/untrusted/untrusted.types.ts +42 -0
- package/src/entrypoints/compact-before.ts +14 -0
- package/src/entrypoints/prompt-submit.ts +25 -0
- package/src/entrypoints/response-after.ts +60 -0
- package/src/entrypoints/run.ts +179 -0
- package/src/entrypoints/session-end.ts +58 -0
- package/src/entrypoints/session-start.ts +172 -0
- package/src/entrypoints/shim.ts +46 -0
- package/src/entrypoints/stop.ts +876 -0
- package/src/entrypoints/subagent-start.ts +16 -0
- package/src/entrypoints/subagent-stop.ts +32 -0
- package/src/entrypoints/support.ts +182 -0
- package/src/entrypoints/tool-after.ts +111 -0
- package/src/entrypoints/tool-before.ts +186 -0
- package/src/entrypoints/tool-failure.ts +26 -0
- package/src/platform/backoff.ts +49 -0
- package/src/platform/cli-output.ts +30 -0
- package/src/platform/env-scope.ts +22 -0
- package/src/platform/fs-atomic.ts +142 -0
- package/src/platform/fs-jsonl.ts +24 -0
- package/src/platform/git.ts +187 -0
- package/src/platform/paths.ts +80 -0
- package/src/platform/pricing.ts +229 -0
- package/src/platform/process.ts +64 -0
- package/src/platform/sanitize.ts +23 -0
- package/src/platform/screen.ts +46 -0
- package/src/platform/style.ts +103 -0
- package/src/providers/claude/claude.capabilities.ts +25 -0
- package/src/providers/claude/claude.detect.ts +14 -0
- package/src/providers/claude/claude.inbound.ts +252 -0
- package/src/providers/claude/claude.lessons-view.ts +33 -0
- package/src/providers/claude/claude.outbound.ts +65 -0
- package/src/providers/claude/claude.policy-defaults.ts +9 -0
- package/src/providers/claude/claude.transcript.ts +62 -0
- package/src/providers/claude/claude.wiring.ts +245 -0
- package/src/providers/claude/index.ts +17 -0
- package/src/providers/cursor/cursor.capabilities.ts +28 -0
- package/src/providers/cursor/cursor.detect.ts +14 -0
- package/src/providers/cursor/cursor.inbound.ts +203 -0
- package/src/providers/cursor/cursor.lessons-view.ts +30 -0
- package/src/providers/cursor/cursor.outbound.ts +43 -0
- package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
- package/src/providers/cursor/cursor.wiring.ts +214 -0
- package/src/providers/cursor/index.ts +17 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider.degrade.ts +152 -0
- package/src/providers/provider.port.ts +20 -0
- package/src/providers/provider.registry.ts +28 -0
- package/tools/doctor.ts +631 -0
- package/tools/help-topic.ts +74 -0
- package/tools/init-project.ts +277 -0
- package/tools/install-runtime.ts +164 -0
- package/tools/lessons-cli.ts +474 -0
- package/tools/obs-cli.ts +175 -0
- package/tools/price-lookup.ts +57 -0
- package/tools/refresh-model-prices.ts +210 -0
- package/tools/test-env.mjs +28 -0
- package/tools/test-env.names.d.mts +1 -0
- package/tools/test-env.names.mjs +14 -0
- 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
|
+
}
|