@workit-dev/core 0.0.1 → 1.0.0
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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
// The before-write gate (S17). With no writer lease, a branch task's unmet
|
|
2
|
+
// before-write requirements (an open product choice, a missing plan) deny
|
|
3
|
+
// working-tree edits where the host has a pre-write hook, naming the exact
|
|
4
|
+
// unblock. History moves (commit, merge, rebase, stash pop) are not edits.
|
|
5
|
+
// Reads only the task index, one task snapshot and the ledger; never
|
|
6
|
+
// migrates, never captures a candidate, and fails open on any state error.
|
|
7
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileSignature, TaskStore } from "../core/task-store";
|
|
11
|
+
import { latestJudgment, writeBlockers, type WriteBlocker } from "../core/policy/derive";
|
|
12
|
+
import { applicableDecision } from "../core/task-evaluation";
|
|
13
|
+
import {
|
|
14
|
+
claimLiveSession,
|
|
15
|
+
liveConflict,
|
|
16
|
+
type LiveConflict,
|
|
17
|
+
type LiveDeps,
|
|
18
|
+
type LiveOwner,
|
|
19
|
+
} from "../core/live-session";
|
|
20
|
+
import { resolveStateDir } from "../core/logger";
|
|
21
|
+
import { workspaceLiveSessionGuard } from "../core/workspaces";
|
|
22
|
+
import { appendHookObserved, ledgerPath } from "../ledger";
|
|
23
|
+
import type { HookDecision } from "./protocol";
|
|
24
|
+
import {
|
|
25
|
+
inWorkitWorkspace,
|
|
26
|
+
rawInvocations,
|
|
27
|
+
workitCheckTail,
|
|
28
|
+
skipWorkitGlobals,
|
|
29
|
+
workitInvocations,
|
|
30
|
+
type RawInvocation,
|
|
31
|
+
} from "./raw-git";
|
|
32
|
+
import { segmentsOf, type Segment, type ShellDialect } from "./shell-words";
|
|
33
|
+
|
|
34
|
+
const NONE: HookDecision = { kind: "none" };
|
|
35
|
+
|
|
36
|
+
const CASE_INSENSITIVE = process.platform === "darwin" || process.platform === "win32";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The real path of `file`: the nearest existing ancestor resolved through
|
|
40
|
+
* symlinks (macOS /var → /private/var), then the not-yet-existing rest.
|
|
41
|
+
*/
|
|
42
|
+
const canonical = (file: string): string => {
|
|
43
|
+
let rest = "";
|
|
44
|
+
for (let dir = path.resolve(file); ; dir = path.dirname(dir)) {
|
|
45
|
+
try {
|
|
46
|
+
// Native resolution also expands Windows 8.3 short names (RUNNER~1).
|
|
47
|
+
return path.join(realpathSync.native(dir), rest);
|
|
48
|
+
} catch {
|
|
49
|
+
if (path.dirname(dir) === dir) return path.resolve(file);
|
|
50
|
+
rest = rest ? path.join(path.basename(dir), rest) : path.basename(dir);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Writes the gate never blocks: files outside the checkout (e.g. /tmp),
|
|
57
|
+
* Markdown (*.md, *.mdx), the top-level `docs/` tree, any `plans/` directory,
|
|
58
|
+
* and the plan files the task's judgment cites.
|
|
59
|
+
*/
|
|
60
|
+
const exempt = (cwd: string, root: string, file: string, plans: ReadonlySet<string>): boolean => {
|
|
61
|
+
const fold = (value: string) => (CASE_INSENSITIVE ? value.toLowerCase() : value);
|
|
62
|
+
const relative = path.relative(fold(canonical(root)), fold(canonical(path.resolve(cwd, file))));
|
|
63
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return true;
|
|
64
|
+
const posix = relative.split(path.sep).join("/");
|
|
65
|
+
const segments = posix.split("/");
|
|
66
|
+
return (
|
|
67
|
+
/\.mdx?$/i.test(posix) ||
|
|
68
|
+
segments[0] === "docs" ||
|
|
69
|
+
segments.includes("plans") ||
|
|
70
|
+
[...plans].some((plan) => fold(plan) === posix)
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "xargs", "busybox"]);
|
|
75
|
+
const operands = (words: string[]) => words.filter((item) => !item.startsWith("-"));
|
|
76
|
+
|
|
77
|
+
const SCRIPT_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The script `workit check … -- <cmd>` runs, so the gates judge it as if it
|
|
81
|
+
* ran raw: the `--shell` string, a `bash -c` script, or the argv. Other
|
|
82
|
+
* workit verbs stay exempt (null).
|
|
83
|
+
*/
|
|
84
|
+
const wrappedScript = (input: string[]): string | null => {
|
|
85
|
+
let words = input;
|
|
86
|
+
while (words.length && (PREFIXES.has(words[0]) || /^\w+=/.test(words[0]))) words = words.slice(1);
|
|
87
|
+
const tail = workitCheckTail(words);
|
|
88
|
+
if (!tail) return null;
|
|
89
|
+
if ("script" in tail) return tail.script;
|
|
90
|
+
const script = shellScript(tail.argv);
|
|
91
|
+
if (SCRIPT_SHELLS.has((tail.argv[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase()) && script)
|
|
92
|
+
return script;
|
|
93
|
+
return tail.argv.map(quoteWord).join(" ");
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The files one simple command edits: [] none, null unknown. */
|
|
97
|
+
const editsOf = (input: string[]): string[] | null => {
|
|
98
|
+
let words = input;
|
|
99
|
+
while (words.length && (PREFIXES.has(words[0]) || /^\w+=/.test(words[0]))) words = words.slice(1);
|
|
100
|
+
const [verb, ...args] = words;
|
|
101
|
+
const name = (verb?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
|
|
102
|
+
if (!name || name === "workit" || (name === "npx" && args.some((item) => /workit/.test(item))))
|
|
103
|
+
return [];
|
|
104
|
+
switch (name) {
|
|
105
|
+
case "touch":
|
|
106
|
+
case "mkdir":
|
|
107
|
+
case "rm":
|
|
108
|
+
case "rmdir":
|
|
109
|
+
case "tee":
|
|
110
|
+
case "mv":
|
|
111
|
+
case "truncate":
|
|
112
|
+
return operands(args);
|
|
113
|
+
case "cp":
|
|
114
|
+
case "ln":
|
|
115
|
+
case "install": {
|
|
116
|
+
const target = args.indexOf("-t");
|
|
117
|
+
if (target >= 0) return args[target + 1] ? [args[target + 1]] : null;
|
|
118
|
+
const last = operands(args).at(-1);
|
|
119
|
+
return last ? [last] : null;
|
|
120
|
+
}
|
|
121
|
+
case "dd":
|
|
122
|
+
return args.filter((item) => item.startsWith("of=")).map((item) => item.slice(3));
|
|
123
|
+
case "sed":
|
|
124
|
+
case "perl": {
|
|
125
|
+
if (!args.some((item) => /^-[a-zA-Z]*i|^--in-place/.test(item))) return [];
|
|
126
|
+
const scripted = args.some((item) => item === "-e" || item === "-f");
|
|
127
|
+
const files: string[] = [];
|
|
128
|
+
for (let index = 0; index < args.length; index++) {
|
|
129
|
+
if (args[index] === "-e" || args[index] === "-f") index++;
|
|
130
|
+
else if (!args[index].startsWith("-")) files.push(args[index]);
|
|
131
|
+
}
|
|
132
|
+
return scripted ? files : files.slice(1);
|
|
133
|
+
}
|
|
134
|
+
case "patch":
|
|
135
|
+
return null;
|
|
136
|
+
// PowerShell (Claude Code's PowerShell tool): the path parameter, else the first operand.
|
|
137
|
+
case "set-content":
|
|
138
|
+
case "add-content":
|
|
139
|
+
case "out-file":
|
|
140
|
+
case "new-item":
|
|
141
|
+
case "ni":
|
|
142
|
+
case "remove-item":
|
|
143
|
+
case "del":
|
|
144
|
+
case "copy-item":
|
|
145
|
+
case "move-item": {
|
|
146
|
+
const flag = args.findIndex((item) =>
|
|
147
|
+
/^-(?:path|filepath|literalpath|destination)$/i.test(item),
|
|
148
|
+
);
|
|
149
|
+
if (flag >= 0) return args[flag + 1] ? [args[flag + 1]] : null;
|
|
150
|
+
const files = operands(args);
|
|
151
|
+
if (name === "move-item") return files.length ? files : null;
|
|
152
|
+
const file = name === "copy-item" ? files.at(-1) : files[0];
|
|
153
|
+
return file ? [file] : null;
|
|
154
|
+
}
|
|
155
|
+
case "git": {
|
|
156
|
+
const sub = operands(args)[0];
|
|
157
|
+
if (sub === "apply") return null;
|
|
158
|
+
if (sub === "restore" || sub === "mv" || sub === "rm") return operands(args).slice(1);
|
|
159
|
+
if (sub === "checkout" && args.includes("--")) return args.slice(args.indexOf("--") + 1);
|
|
160
|
+
// commit, merge, rebase, stash, cherry-pick… move history, not edits.
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
default:
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** A `bash -c '<script>'` (any SCRIPT_SHELLS) script, else null. */
|
|
169
|
+
const shellScript = (words: readonly string[]): string | null => {
|
|
170
|
+
if (!SCRIPT_SHELLS.has((words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase())) return null;
|
|
171
|
+
// Options may come before `-c` (`bash --norc -c`, `sh -e -c`); the script follows it.
|
|
172
|
+
for (let index = 1; index < words.length; index++) {
|
|
173
|
+
const word = words[index];
|
|
174
|
+
if (/^-[a-z]*c$/.test(word)) return words[index + 1] ?? null;
|
|
175
|
+
if (/^[-+][oO]$/.test(word)) index++;
|
|
176
|
+
else if (!/^[-+]/.test(word)) return null;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** xargs options that take the next word as their value. */
|
|
182
|
+
const XARGS_VALUE = /^-[IinLPdsEa]$/;
|
|
183
|
+
|
|
184
|
+
/** The command `xargs` (or `find -exec`) runs with `{}` filled in by a path. */
|
|
185
|
+
const runsWriter = (words: readonly string[]): boolean => {
|
|
186
|
+
const filled = [...words.filter((word) => word !== "{}" && word !== ";" && word !== "+"), "x"];
|
|
187
|
+
const edits = editsOf(filled);
|
|
188
|
+
return edits === null || edits.length > 0 || opaqueWrites(filled) !== null;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* For an implementer's confinement: commands that may write where the edit
|
|
193
|
+
* analysis cannot see (interpreters, chmod, rsync, `find -delete`, `xargs rm`,
|
|
194
|
+
* `awk -i`, a `-c` shell script): the paths they may change, "." standing for
|
|
195
|
+
* the directory they run in; null when the command is none of them.
|
|
196
|
+
*/
|
|
197
|
+
const opaqueWrites = (
|
|
198
|
+
input: readonly string[],
|
|
199
|
+
scriptOf: (index: number) => string | undefined = (index) => input[index],
|
|
200
|
+
): string[] | null => {
|
|
201
|
+
let words = input;
|
|
202
|
+
while (words.length && (PREFIXES.has(words[0]) || /^\w+=/.test(words[0]))) {
|
|
203
|
+
if (words[0] === "xargs") {
|
|
204
|
+
let at = 1;
|
|
205
|
+
while (words[at]?.startsWith("-")) at += XARGS_VALUE.test(words[at]) ? 2 : 1;
|
|
206
|
+
return runsWriter(words.slice(at)) ? ["."] : null;
|
|
207
|
+
}
|
|
208
|
+
words = words.slice(1);
|
|
209
|
+
}
|
|
210
|
+
const [verb, ...args] = words;
|
|
211
|
+
const name = (verb?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
|
|
212
|
+
const files = operands(args);
|
|
213
|
+
const has = (pattern: RegExp) => args.some((arg) => pattern.test(arg));
|
|
214
|
+
const listed = (named: string[]) => (named.length ? named : ["."]);
|
|
215
|
+
if (name === "unlink" || name === "shred") return listed(files);
|
|
216
|
+
// The mode or owner comes first, unless `--reference` names it.
|
|
217
|
+
if (/^(?:chmod|chown|chgrp|chattr)$/.test(name))
|
|
218
|
+
return listed(has(/^--reference/) ? files : files.slice(1));
|
|
219
|
+
if (name === "rsync") {
|
|
220
|
+
const dest = files.at(-1);
|
|
221
|
+
// `host:path` is remote; a drive letter (`C:/…`, `C:\…`) is a local path.
|
|
222
|
+
const remote = /^[\w.@-]*:/.test(dest ?? "") && !/^[A-Za-z]:[\\/]/.test(dest ?? "");
|
|
223
|
+
return dest === undefined ? ["."] : remote ? [] : [dest];
|
|
224
|
+
}
|
|
225
|
+
if (name === "find") {
|
|
226
|
+
const exec = args.findIndex((arg) => /^-(?:exec|execdir|ok|okdir)$/.test(arg));
|
|
227
|
+
const writes =
|
|
228
|
+
has(/^-(?:delete|fprint\w*|fls)$/) || (exec >= 0 && runsWriter(args.slice(exec + 1)));
|
|
229
|
+
const end = args.findIndex((arg) => /^[-(!]/.test(arg));
|
|
230
|
+
return writes ? listed(end < 0 ? args : args.slice(0, end)) : null;
|
|
231
|
+
}
|
|
232
|
+
if (/^g?awk$/.test(name)) {
|
|
233
|
+
if (!has(/^-i|^--include/)) return null;
|
|
234
|
+
const named: string[] = [];
|
|
235
|
+
let program = !has(/^-f$|^--file/);
|
|
236
|
+
for (let index = 0; index < args.length; index++)
|
|
237
|
+
if (/^(?:-[ifvF]|--(?:include|file|assign|field-separator))$/.test(args[index])) index++;
|
|
238
|
+
else if (args[index].startsWith("-")) continue;
|
|
239
|
+
else if (program) program = false;
|
|
240
|
+
else named.push(args[index]);
|
|
241
|
+
return listed(named);
|
|
242
|
+
}
|
|
243
|
+
const inline = (flag: RegExp): string[] | null => {
|
|
244
|
+
const at = args.findIndex((arg) => flag.test(arg));
|
|
245
|
+
if (at < 0) return null;
|
|
246
|
+
const runs = input.length - args.length - 1;
|
|
247
|
+
// The script, the interpreter's options and arguments, and the environment
|
|
248
|
+
// it is given (`X=/p node -e …`, `NODE_OPTIONS=--require …`).
|
|
249
|
+
return [
|
|
250
|
+
".",
|
|
251
|
+
...input.flatMap((word, index) => {
|
|
252
|
+
if (index === runs || (index < runs && !/^\w+=/.test(word))) return [];
|
|
253
|
+
const text = scriptOf(index);
|
|
254
|
+
return text === undefined ? [unknownVarTarget(word)] : pathsIn(text);
|
|
255
|
+
}),
|
|
256
|
+
];
|
|
257
|
+
};
|
|
258
|
+
if (name === "perl") return inline(/^-[a-zA-Z]*[eE]$/);
|
|
259
|
+
if (/^(?:python[\d.]*|node|bun|deno|ruby|php)$/.test(name))
|
|
260
|
+
return inline(/^-[a-zA-Z]*[ce]$|^--eval$|^-p$/);
|
|
261
|
+
return shellScript(words) === null ? null : ["."];
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The paths an inline script or its arguments spell out: every absolute or
|
|
266
|
+
* `~/` path, and every relative one that climbs with `..`. An interpreter's
|
|
267
|
+
* inline script may write anywhere it names, so each counts as a change, with
|
|
268
|
+
* the directory it runs in.
|
|
269
|
+
*/
|
|
270
|
+
const pathsIn = (text: string): string[] =>
|
|
271
|
+
[
|
|
272
|
+
...text.matchAll(
|
|
273
|
+
/(?<![\w.~:/$-])(?:~\/|\/|[A-Za-z]:\/|\.\.(?:\/|$))[^\s'"`$),;:*?<>|\\[\]{}]*/g,
|
|
274
|
+
),
|
|
275
|
+
].map((match) => match[0]);
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* `ln`'s link sources, resolved as the link will: a relative symbolic source
|
|
279
|
+
* against the directory the link lands in. Writes through such a link change
|
|
280
|
+
* its source, so an implementer's links are judged by where they point.
|
|
281
|
+
*/
|
|
282
|
+
const linkSources = (args: readonly string[], at: (file: string) => string | null): string[] => {
|
|
283
|
+
const names = operands([...args]);
|
|
284
|
+
const into = args.indexOf("-t");
|
|
285
|
+
const dest = into >= 0 ? args[into + 1] : names.length > 1 ? names.at(-1) : ".";
|
|
286
|
+
const sources = names.filter((name, index) =>
|
|
287
|
+
into >= 0 ? name !== dest : index < names.length - 1 || names.length === 1,
|
|
288
|
+
);
|
|
289
|
+
const target = dest === undefined ? null : at(dest);
|
|
290
|
+
const symbolic = args.some((arg) => /^-[a-zA-Z]*s|^--symbolic$/.test(arg));
|
|
291
|
+
const base =
|
|
292
|
+
target === null
|
|
293
|
+
? null
|
|
294
|
+
: into >= 0 || sources.length > 1 || existsSync(target) || /[\\/]$/.test(dest ?? "")
|
|
295
|
+
? target
|
|
296
|
+
: path.dirname(target);
|
|
297
|
+
return sources.flatMap((source) => {
|
|
298
|
+
if (symbolic && base !== null && !path.isAbsolute(homeExpanded(source)))
|
|
299
|
+
return [path.resolve(base, source)];
|
|
300
|
+
const resolved = at(source);
|
|
301
|
+
return resolved ? [resolved] : [];
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Whether a shell command recognizably edits files (redirects, tee, touch,
|
|
307
|
+
* mkdir, cp/mv/rm, `sed -i`/`perl -i`, dd, and working-tree git: apply,
|
|
308
|
+
* restore, checkout --, mv, rm). `targets` lists the files when they are all
|
|
309
|
+
* known, else null. Interpreters and formatters are not detected; Workit's
|
|
310
|
+
* own commands never count.
|
|
311
|
+
*/
|
|
312
|
+
export const shellWrites = (
|
|
313
|
+
command: string,
|
|
314
|
+
depth = 0,
|
|
315
|
+
): { writes: boolean; targets: string[] | null } => {
|
|
316
|
+
const targets: string[] = [];
|
|
317
|
+
let unknown = false;
|
|
318
|
+
for (const segment of segmentsOf(command)) {
|
|
319
|
+
const inner = depth < 4 ? wrappedScript(segment.words) : null;
|
|
320
|
+
if (inner !== null) {
|
|
321
|
+
const nested = shellWrites(inner, depth + 1);
|
|
322
|
+
if (nested.writes && nested.targets === null) unknown = true;
|
|
323
|
+
else if (nested.targets) targets.push(...nested.targets);
|
|
324
|
+
}
|
|
325
|
+
targets.push(
|
|
326
|
+
...segment.redirects.filter((item) => !/^\/dev\/(?:null|stdout|stderr|tty)$/.test(item)),
|
|
327
|
+
);
|
|
328
|
+
const edits = editsOf(segment.words);
|
|
329
|
+
if (edits === null) unknown = true;
|
|
330
|
+
else targets.push(...edits);
|
|
331
|
+
}
|
|
332
|
+
if (!unknown && targets.length === 0) return { writes: false, targets: null };
|
|
333
|
+
return { writes: true, targets: unknown ? null : targets };
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
type Gate = { blockers: WriteBlocker[]; root: string; plans: Set<string> };
|
|
337
|
+
type Cached = Gate & { key: string; planKey: string };
|
|
338
|
+
const cache = new Map<string, Cached>();
|
|
339
|
+
|
|
340
|
+
const planKeyOf = (root: string, plans: Iterable<string>) =>
|
|
341
|
+
[...plans].map((file) => fileSignature(path.resolve(root, file)) ?? "-").join("\0");
|
|
342
|
+
|
|
343
|
+
/** Unmet, unwaived before-write requirements of the checkout branch's open task. */
|
|
344
|
+
const gateFor = (cwd: string): Gate | null => {
|
|
345
|
+
const store = new TaskStore(cwd);
|
|
346
|
+
const listed = store.listTaskIndex();
|
|
347
|
+
if (!listed.ok) return null;
|
|
348
|
+
const key = store.currentKey();
|
|
349
|
+
if (!key.ok) return null;
|
|
350
|
+
const entry = listed.data
|
|
351
|
+
.filter((item) => item.key === key.data.key && item.status !== "closed")
|
|
352
|
+
.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
|
|
353
|
+
if (!entry) return { blockers: [], root: store.root, plans: new Set() };
|
|
354
|
+
const ledger = ledgerPath(store.root);
|
|
355
|
+
const cacheKey = `${entry.id}\0${entry.revision}\0${(ledger.ok && fileSignature(ledger.value)) || "-"}`;
|
|
356
|
+
const hit = cache.get(store.root);
|
|
357
|
+
// A cited plan created or edited since changes the answer (long-lived hosts).
|
|
358
|
+
if (hit && hit.key === cacheKey && hit.planKey === planKeyOf(store.root, hit.plans)) return hit;
|
|
359
|
+
const task = store.readTask(entry.id);
|
|
360
|
+
if (!task.ok) return null;
|
|
361
|
+
const workspace = store.readWorkspace();
|
|
362
|
+
const plans = new Set(
|
|
363
|
+
(latestJudgment(task.data)?.refs ?? []).flatMap((ref) =>
|
|
364
|
+
ref.kind === "file" ? [ref.path] : [],
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
// An approved limitation clears a waivable requirement, as at close.
|
|
368
|
+
const blockers = writeBlockers(task.data, store.root).filter((blocker) => {
|
|
369
|
+
const requirement = task.data.policy?.requirements.find(
|
|
370
|
+
(item) => item.ruleId === blocker.ruleId && item.before === "write",
|
|
371
|
+
);
|
|
372
|
+
return !(
|
|
373
|
+
requirement?.acceptanceAllowed &&
|
|
374
|
+
workspace.ok &&
|
|
375
|
+
workspace.data &&
|
|
376
|
+
applicableDecision(task.data, workspace.data, requirement, store.root).length > 0
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
const gate: Cached = {
|
|
380
|
+
blockers,
|
|
381
|
+
root: store.root,
|
|
382
|
+
plans,
|
|
383
|
+
key: cacheKey,
|
|
384
|
+
planKey: planKeyOf(store.root, plans),
|
|
385
|
+
};
|
|
386
|
+
cache.set(store.root, gate);
|
|
387
|
+
if (cache.size > 32) cache.delete(cache.keys().next().value!);
|
|
388
|
+
return gate;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
// --- the live-session guard (#274) ------------------------------------------
|
|
392
|
+
|
|
393
|
+
/** git subcommands that change a checkout's files, index or HEAD. */
|
|
394
|
+
const MUTATING_GIT = new Set([
|
|
395
|
+
"add",
|
|
396
|
+
"am",
|
|
397
|
+
"apply",
|
|
398
|
+
"checkout",
|
|
399
|
+
"cherry-pick",
|
|
400
|
+
"clean",
|
|
401
|
+
"commit",
|
|
402
|
+
"merge",
|
|
403
|
+
"mv",
|
|
404
|
+
"pull",
|
|
405
|
+
"rebase",
|
|
406
|
+
"reset",
|
|
407
|
+
"restore",
|
|
408
|
+
"revert",
|
|
409
|
+
"rm",
|
|
410
|
+
"stash",
|
|
411
|
+
"switch",
|
|
412
|
+
]);
|
|
413
|
+
/** Subcommands whose `-n` means `--dry-run` (for `commit`, `-n` skips hooks). */
|
|
414
|
+
const DRY_N = new Set(["add", "clean", "rm", "mv"]);
|
|
415
|
+
/** `workit git` verbs that commit, move HEAD or change the index in the checkout. */
|
|
416
|
+
const MUTATING_WORKIT_GIT = new Set(["commit", "branch", "stage", "unstage"]);
|
|
417
|
+
|
|
418
|
+
/** A placeholder file name that stands for its directory (an unknown write, a git command). */
|
|
419
|
+
const DIR_TARGET = ".workit-live-target";
|
|
420
|
+
|
|
421
|
+
/** A one-command override: `WORKIT_LIVE_SESSION=override <command>`. */
|
|
422
|
+
const OVERRIDE = "WORKIT_LIVE_SESSION=override";
|
|
423
|
+
|
|
424
|
+
/** Only asks for help or a dry run: changes nothing. */
|
|
425
|
+
const inert = (args: readonly string[], sub: string): boolean =>
|
|
426
|
+
args.some(
|
|
427
|
+
(arg) =>
|
|
428
|
+
arg === "--help" ||
|
|
429
|
+
arg === "-h" ||
|
|
430
|
+
arg === "--dry-run" ||
|
|
431
|
+
(DRY_N.has(sub) && /^-[a-zA-Z]*n[a-zA-Z]*$/.test(arg) && !arg.startsWith("--")),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The directories mutating git commands in `command` run in (raw-git's
|
|
436
|
+
* analysis: `cd`/`pushd`, groups, `-C`, env and wrapper prefixes, `bash -c`,
|
|
437
|
+
* `$(…)`, `eval`, `xargs`, `find -exec`, PowerShell, `cmd /c`). A command
|
|
438
|
+
* whose directory cannot be known is skipped, never denied.
|
|
439
|
+
*/
|
|
440
|
+
const gitMutationDirs = (cwd: string, command: string, dialect: ShellDialect): string[] => {
|
|
441
|
+
let invocations: readonly RawInvocation[];
|
|
442
|
+
try {
|
|
443
|
+
invocations = rawInvocations(command, dialect);
|
|
444
|
+
} catch {
|
|
445
|
+
return [];
|
|
446
|
+
}
|
|
447
|
+
return invocations.flatMap((invocation) => {
|
|
448
|
+
if (invocation.tool !== "git" || invocation.dir === undefined) return [];
|
|
449
|
+
const at = invocation.args.findIndex((arg) => !arg.startsWith("-"));
|
|
450
|
+
const sub = invocation.args[at] ?? "";
|
|
451
|
+
const rest = invocation.args.slice(at + 1);
|
|
452
|
+
if (!MUTATING_GIT.has(sub) || inert(rest, sub)) return [];
|
|
453
|
+
// `git stash list|show` only reads.
|
|
454
|
+
if (sub === "stash" && ["list", "show"].includes(rest[0] ?? "")) return [];
|
|
455
|
+
return [path.resolve(cwd, invocation.dir ?? ".")];
|
|
456
|
+
});
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const CD = new Set(["cd", "chdir", "set-location", "sl"]);
|
|
460
|
+
const PUSHD = new Set(["pushd", "push-location"]);
|
|
461
|
+
const POPD = new Set(["popd", "pop-location"]);
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Stands for a directory an implementer's command moved to but the analysis
|
|
465
|
+
* cannot name (`cd -`, `cd "$X"`, a bare `popd`): a write that lands under it
|
|
466
|
+
* is denied by `confineImplementer`, as the shell may be anywhere.
|
|
467
|
+
*/
|
|
468
|
+
export const UNKNOWN_DIR = path.resolve(path.parse(process.cwd()).root, ".workit-unknown-dir");
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* A `cd` operand resolved against `dir`. A target that cannot be known is
|
|
472
|
+
* undefined, or `UNKNOWN_DIR` when `strict`.
|
|
473
|
+
*/
|
|
474
|
+
const cdInto = (
|
|
475
|
+
dir: string | undefined,
|
|
476
|
+
words: string[],
|
|
477
|
+
strict = false,
|
|
478
|
+
vars: ReadonlyMap<string, string> = new Map(),
|
|
479
|
+
): string | undefined => {
|
|
480
|
+
const lost = strict ? UNKNOWN_DIR : undefined;
|
|
481
|
+
const found = words.slice(1).find((word) => !word.startsWith("-") || word === "-");
|
|
482
|
+
const named = found === undefined ? undefined : expandVars(found, vars);
|
|
483
|
+
// Only a leading `~` or `$HOME` expands; a mid-path `~` is literal (Windows 8.3
|
|
484
|
+
// short names like RUNNER~1).
|
|
485
|
+
const target = named === undefined ? undefined : homeExpanded(named);
|
|
486
|
+
if (target === "-" || (target && /^~|[$`*?]/.test(target))) return lost;
|
|
487
|
+
if (dir === undefined) return lost;
|
|
488
|
+
return target === undefined ? os.homedir() : path.resolve(dir, target);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const quoteWord = (word: string): string =>
|
|
492
|
+
/^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* `$NAME` and `${NAME}` replaced by the value a same-command assignment gave
|
|
496
|
+
* it. A name the command never assigned stays as written.
|
|
497
|
+
*/
|
|
498
|
+
const expandVars = (text: string, vars: ReadonlyMap<string, string>): string =>
|
|
499
|
+
vars.size === 0 || !text.includes("$")
|
|
500
|
+
? text
|
|
501
|
+
: text.replace(/\$(?:(\w+)|\{(\w+)\})/g, (whole, bare?: string, braced?: string) => {
|
|
502
|
+
const value = vars.get(bare ?? braced ?? "");
|
|
503
|
+
return value === undefined ? whole : value;
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const DECLARERS = new Set(["export", "declare", "typeset", "local", "readonly"]);
|
|
507
|
+
|
|
508
|
+
/** The `NAME=value` assignments a segment makes to the shell itself (alone, or after `export`). */
|
|
509
|
+
const assignmentsOf = (words: readonly string[]): Array<[string, string]> => {
|
|
510
|
+
const first = words.findIndex((word) => !/^\w+=/.test(word));
|
|
511
|
+
const named =
|
|
512
|
+
first < 0
|
|
513
|
+
? words
|
|
514
|
+
: first === 0 && DECLARERS.has(words[0])
|
|
515
|
+
? words.slice(1).filter((word) => !word.startsWith("-"))
|
|
516
|
+
: [];
|
|
517
|
+
return named.flatMap((word) => {
|
|
518
|
+
const match = /^(\w+)=(.*)$/s.exec(word);
|
|
519
|
+
return match && /^[A-Za-z_]/.test(match[1]) ? [[match[1], match[2]] as [string, string]] : [];
|
|
520
|
+
});
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/** `words` without the leading `do`, `then`, `else`… that only introduce the command. */
|
|
524
|
+
const commandWords = (words: readonly string[]): string[] => {
|
|
525
|
+
let start = 0;
|
|
526
|
+
while (start < words.length && COMPOUND.has(words[start])) start++;
|
|
527
|
+
return words.slice(start);
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
/** Commands that can set any variable, whatever their arguments name: no variable is trusted after one. */
|
|
531
|
+
const REBINDS_ANY = new Set([
|
|
532
|
+
"source",
|
|
533
|
+
".",
|
|
534
|
+
"eval",
|
|
535
|
+
"trap",
|
|
536
|
+
"alias",
|
|
537
|
+
"declare",
|
|
538
|
+
"typeset",
|
|
539
|
+
"local",
|
|
540
|
+
]);
|
|
541
|
+
|
|
542
|
+
const BINDERS = new Set([
|
|
543
|
+
"read",
|
|
544
|
+
"mapfile",
|
|
545
|
+
"readarray",
|
|
546
|
+
"printf",
|
|
547
|
+
"getopts",
|
|
548
|
+
"unset",
|
|
549
|
+
"eval",
|
|
550
|
+
"let",
|
|
551
|
+
"set",
|
|
552
|
+
"source",
|
|
553
|
+
".",
|
|
554
|
+
"declare",
|
|
555
|
+
"typeset",
|
|
556
|
+
"local",
|
|
557
|
+
"readonly",
|
|
558
|
+
"export",
|
|
559
|
+
"builtin",
|
|
560
|
+
"command",
|
|
561
|
+
]);
|
|
562
|
+
const COMPOUND = new Set([
|
|
563
|
+
"if",
|
|
564
|
+
"then",
|
|
565
|
+
"elif",
|
|
566
|
+
"else",
|
|
567
|
+
"fi",
|
|
568
|
+
"for",
|
|
569
|
+
"while",
|
|
570
|
+
"until",
|
|
571
|
+
"do",
|
|
572
|
+
"done",
|
|
573
|
+
"case",
|
|
574
|
+
"esac",
|
|
575
|
+
"select",
|
|
576
|
+
"function",
|
|
577
|
+
"{",
|
|
578
|
+
"}",
|
|
579
|
+
]);
|
|
580
|
+
|
|
581
|
+
/** Keywords that open and close a compound body; `case`, `select` and functions are not followed. */
|
|
582
|
+
const OPENS = new Set(["if", "for", "while", "until", "{"]);
|
|
583
|
+
const CLOSES = new Set(["fi", "done", "}"]);
|
|
584
|
+
const UNFOLLOWED = new Set(["case", "esac", "select", "function"]);
|
|
585
|
+
/** Words before the command that leave it the command: `builtin eval` is still `eval`. */
|
|
586
|
+
const TRANSPARENT = new Set(["builtin", "command", "time", "!"]);
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Which variables a command's assignments may not be trusted for: none at all
|
|
590
|
+
* after `case`, `select`, a function or a command that can set any variable
|
|
591
|
+
* (`eval`, `source`, `declare`…) (`all`); else the names assigned inside a
|
|
592
|
+
* loop or conditional body, and the names a conditional or non-plain
|
|
593
|
+
* assignment (`+=`, `a[i]=`), a binder (`read`, `unset`, `declare -n`,
|
|
594
|
+
* `printf -v`…) or `${N:=…}` can change. A `for` variable is followed
|
|
595
|
+
* apart, in `segmentTargets`. Only a plain `NAME=literal` that runs in
|
|
596
|
+
* sequence, or `export NAME=literal`, is substituted.
|
|
597
|
+
*/
|
|
598
|
+
const untrustedVars = (
|
|
599
|
+
segments: readonly Segment[],
|
|
600
|
+
): { all: boolean; names: ReadonlySet<string> } => {
|
|
601
|
+
const names = new Set<string>();
|
|
602
|
+
let all = false;
|
|
603
|
+
let depth = 0;
|
|
604
|
+
const poison = (text: string) => {
|
|
605
|
+
for (const match of text.matchAll(/[A-Za-z_]\w*/g)) names.add(match[0]);
|
|
606
|
+
};
|
|
607
|
+
for (const segment of segments) {
|
|
608
|
+
let start = 0;
|
|
609
|
+
for (; start < segment.words.length && COMPOUND.has(segment.words[start]); start++) {
|
|
610
|
+
const keyword = segment.words[start];
|
|
611
|
+
if (UNFOLLOWED.has(keyword)) all = true;
|
|
612
|
+
if (OPENS.has(keyword)) depth++;
|
|
613
|
+
else if (CLOSES.has(keyword)) depth = Math.max(0, depth - 1);
|
|
614
|
+
}
|
|
615
|
+
const words = segment.words.slice(start);
|
|
616
|
+
if (depth > 0) for (const [key] of assignmentsOf(words)) names.add(key);
|
|
617
|
+
const lead = words.findIndex((word) => !/^\w+\+?=|^\w+\[.*\]\+?=/.test(word));
|
|
618
|
+
const first = lead < 0 ? undefined : words[lead];
|
|
619
|
+
let command = lead;
|
|
620
|
+
while (command >= 0 && TRANSPARENT.has(words[command] ?? "")) command++;
|
|
621
|
+
if (command >= 0 && REBINDS_ANY.has(words[command] ?? "")) all = true;
|
|
622
|
+
if (command >= 0 && UNFOLLOWED.has(words[command] ?? "")) all = true;
|
|
623
|
+
for (const word of words)
|
|
624
|
+
for (const match of word.matchAll(/\$\{(\w+)(?:\[[^\]]*\])?:?[=]/g)) names.add(match[1]);
|
|
625
|
+
const assigned = lead < 0 ? words : first === "export" ? words.slice(lead + 1) : [];
|
|
626
|
+
const plainOnly = assigned.every((word) => /^\w+=/.test(word) || word === "export");
|
|
627
|
+
if (segment.conditional || (!plainOnly && lead < 0))
|
|
628
|
+
for (const word of assigned) poison(word.split("=")[0]);
|
|
629
|
+
if (lead >= 0 && words.slice(lead).some((word) => BINDERS.has(word))) {
|
|
630
|
+
const flagged = words.slice(lead + 1).some((word) => word.startsWith("-"));
|
|
631
|
+
const plainExport = first === "export" && !flagged;
|
|
632
|
+
if (!plainExport) poison(words.slice(lead).join(" "));
|
|
633
|
+
else if (segment.conditional) poison(words.slice(lead).join(" "));
|
|
634
|
+
}
|
|
635
|
+
if (words.some((word) => /^\w+\+=/.test(word)))
|
|
636
|
+
for (const word of words) if (/^\w+\+=/.test(word)) poison(word.split("+")[0]);
|
|
637
|
+
}
|
|
638
|
+
return { all, names };
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Where a variable reference stands in a path Workit cannot resolve: a write
|
|
643
|
+
* under it is denied by `confineImplementer`, naming the references
|
|
644
|
+
* (URI-encoded, one path segment).
|
|
645
|
+
*/
|
|
646
|
+
export const UNKNOWN_VAR = path.resolve(path.parse(process.cwd()).root, ".workit-unknown-var");
|
|
647
|
+
|
|
648
|
+
const REFERENCE = /\$\{(\w+)[^}]*\}|\$(\w+)|\$\([^)]*\)?|`[^`]*`?/g;
|
|
649
|
+
|
|
650
|
+
/** The variable references and substitutions left in `text`, as written (`$T`, `$(cat f)`). */
|
|
651
|
+
export const unresolvedRefs = (text: string): string[] => [
|
|
652
|
+
...new Set(
|
|
653
|
+
[...text.matchAll(REFERENCE)].map((match) => {
|
|
654
|
+
const name = match[1] ?? match[2];
|
|
655
|
+
return name === undefined ? match[0] : `$${name}`;
|
|
656
|
+
}),
|
|
657
|
+
),
|
|
658
|
+
];
|
|
659
|
+
|
|
660
|
+
const unknownVarTarget = (text: string): string =>
|
|
661
|
+
path.join(UNKNOWN_VAR, encodeURIComponent(unresolvedRefs(text).join(" ") || text));
|
|
662
|
+
|
|
663
|
+
/** Literal-pattern parameter operators: `/`, `//`, `%`, `%%`, `#`, `##`; null for any other. */
|
|
664
|
+
const applyOperator = (value: string, operator: string): string | null => {
|
|
665
|
+
if (operator === "") return value;
|
|
666
|
+
const swap = /^(\/\/?)([^/*?[\]]+)\/([^*?[\]]*)$/.exec(operator);
|
|
667
|
+
if (swap)
|
|
668
|
+
return swap[1] === "//" ? value.replaceAll(swap[2], swap[3]) : value.replace(swap[2], swap[3]);
|
|
669
|
+
const trim = /^(%%?|##?)([^*?[\]]*)$/.exec(operator);
|
|
670
|
+
if (!trim) return null;
|
|
671
|
+
if (trim[1].startsWith("%"))
|
|
672
|
+
return value.endsWith(trim[2]) ? value.slice(0, value.length - trim[2].length) : value;
|
|
673
|
+
return value.startsWith(trim[2]) ? value.slice(trim[2].length) : value;
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
/** One word expands to at most this many texts; past it, the reference stays unresolved. */
|
|
677
|
+
const MAX_EXPANSIONS = 16;
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Every text `text` may expand to: each `$NAME`, `${NAME}` or literal
|
|
681
|
+
* `${NAME/a/b}`-style reference replaced by the value an assignment gave it,
|
|
682
|
+
* or by each word of the `for` list it loops over. Other references stay as
|
|
683
|
+
* written.
|
|
684
|
+
*/
|
|
685
|
+
const expansions = (
|
|
686
|
+
text: string,
|
|
687
|
+
vars: ReadonlyMap<string, string>,
|
|
688
|
+
loops: ReadonlyMap<string, readonly string[]>,
|
|
689
|
+
): string[] => {
|
|
690
|
+
if (!text.includes("$")) return [text];
|
|
691
|
+
let results = [""];
|
|
692
|
+
let last = 0;
|
|
693
|
+
for (const match of text.matchAll(/\$\{(\w+)([^}]*)\}|\$(\w+)/g)) {
|
|
694
|
+
const name = match[1] ?? match[3];
|
|
695
|
+
const literal = text.slice(last, match.index);
|
|
696
|
+
last = match.index + match[0].length;
|
|
697
|
+
const value = vars.get(name);
|
|
698
|
+
const values = loops.get(name) ?? (value === undefined ? null : [value]);
|
|
699
|
+
const applied = values?.map((item) => applyOperator(item, match[2] ?? ""));
|
|
700
|
+
const fits = applied && results.length * applied.length <= MAX_EXPANSIONS;
|
|
701
|
+
results =
|
|
702
|
+
fits && applied.every((item) => item !== null)
|
|
703
|
+
? results.flatMap((prefix) => applied.map((item) => prefix + literal + item))
|
|
704
|
+
: results.map((prefix) => prefix + literal + match[0]);
|
|
705
|
+
}
|
|
706
|
+
return results.map((prefix) => prefix + text.slice(last));
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The path `$(mktemp [-d] [-p DIR|-t] [TEMPLATE])` prints: TEMPLATE under DIR,
|
|
711
|
+
* the temp dir, or (no `-p`/`-t`) the directory it runs in; null for any
|
|
712
|
+
* other value.
|
|
713
|
+
*/
|
|
714
|
+
const mktempPath = (value: string, dir: string | undefined): string | null => {
|
|
715
|
+
const match = /^\$\(\s*mktemp\b([^()`$]*)\)$/.exec(value.trim());
|
|
716
|
+
if (!match) return null;
|
|
717
|
+
const args = match[1]
|
|
718
|
+
.trim()
|
|
719
|
+
.split(/\s+/)
|
|
720
|
+
.filter(Boolean)
|
|
721
|
+
.map((arg) => arg.replace(/^(["'])(.*)\1$/, "$2"));
|
|
722
|
+
let base: string | null = null;
|
|
723
|
+
let template: string | undefined;
|
|
724
|
+
let temp = false;
|
|
725
|
+
for (let index = 0; index < args.length; index++) {
|
|
726
|
+
const arg = args[index];
|
|
727
|
+
if (arg === "-p" || arg === "--tmpdir") {
|
|
728
|
+
if (arg === "-p") base = args[++index] ?? null;
|
|
729
|
+
else temp = true;
|
|
730
|
+
if (arg === "-p" && base === null) return null;
|
|
731
|
+
} else if (arg.startsWith("--tmpdir=")) base = arg.slice("--tmpdir=".length);
|
|
732
|
+
else if (arg === "-t") temp = true;
|
|
733
|
+
else if (!arg.startsWith("-")) template = arg;
|
|
734
|
+
}
|
|
735
|
+
if (base === null && (temp || template === undefined)) base = os.tmpdir();
|
|
736
|
+
const name = homeExpanded(template ?? "tmp.XXXXXXXXXX");
|
|
737
|
+
if (path.isAbsolute(name)) return name;
|
|
738
|
+
const from = base === null ? dir : homeExpanded(base);
|
|
739
|
+
if (from === undefined || (!path.isAbsolute(from) && dir === undefined)) return null;
|
|
740
|
+
return path.resolve(dir ?? from, from, name);
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
/** A leading `~`, `$HOME` or `${HOME}` the shell would expand. */
|
|
744
|
+
const homeExpanded = (file: string): string => {
|
|
745
|
+
const match = /^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/.exec(file);
|
|
746
|
+
return match ? (process.env.HOME || os.homedir()) + file.slice(match[0].length) : file;
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Per top-level segment, the files it writes (redirects, `touch`, `rm`,
|
|
751
|
+
* `sed -i`…) and `workit git commit|branch|stage|unstage`, resolved against the
|
|
752
|
+
* directory that segment runs in (`cd`, `pushd`/`popd`, `( … )` groups). A
|
|
753
|
+
* relative target in an unknown directory is skipped.
|
|
754
|
+
*/
|
|
755
|
+
const segmentTargets = (
|
|
756
|
+
cwd: string,
|
|
757
|
+
command: string,
|
|
758
|
+
dialect: ShellDialect,
|
|
759
|
+
depth = 0,
|
|
760
|
+
strict = false,
|
|
761
|
+
inherited: ReadonlyMap<string, string> = new Map(),
|
|
762
|
+
): string[] => {
|
|
763
|
+
const targets: string[] = [];
|
|
764
|
+
let dir: string | undefined = cwd;
|
|
765
|
+
let stack: Array<string | undefined> = [];
|
|
766
|
+
const segments = segmentsOf(command, dialect);
|
|
767
|
+
const untrusted = untrustedVars(segments);
|
|
768
|
+
let vars = new Map(
|
|
769
|
+
untrusted.all ? [] : [...inherited].filter(([key]) => !untrusted.names.has(key)),
|
|
770
|
+
);
|
|
771
|
+
let loops = new Map<string, string[]>();
|
|
772
|
+
let nesting = 0;
|
|
773
|
+
const groups: Array<{
|
|
774
|
+
dir: string | undefined;
|
|
775
|
+
stack: Array<string | undefined>;
|
|
776
|
+
vars: Map<string, string>;
|
|
777
|
+
loops: Map<string, string[]>;
|
|
778
|
+
}> = [];
|
|
779
|
+
const atAll = (file: string, base: string | undefined = dir): string[] =>
|
|
780
|
+
expansions(file, vars, loops).flatMap((text) => {
|
|
781
|
+
const home = homeExpanded(text);
|
|
782
|
+
if (strict && unresolvedRefs(home).length > 0) return [unknownVarTarget(home)];
|
|
783
|
+
if (path.isAbsolute(home)) return [home];
|
|
784
|
+
return base === undefined ? [] : [path.resolve(base, home)];
|
|
785
|
+
});
|
|
786
|
+
const at = (file: string): string | null => {
|
|
787
|
+
const all = atAll(file);
|
|
788
|
+
return all.length > 1 ? unknownVarTarget(file) : (all[0] ?? null);
|
|
789
|
+
};
|
|
790
|
+
/** A reference the command spells without a `\\` before its `$` somewhere: the shell expands it. */
|
|
791
|
+
const unescaped = (ref: string): boolean => {
|
|
792
|
+
const name = /^\$(\w+)$/.exec(ref)?.[1];
|
|
793
|
+
return (
|
|
794
|
+
name === undefined || new RegExp(`(?<!\\\\)\\$(?:${name}(?!\\w)|\\{${name}\\b)`).test(command)
|
|
795
|
+
);
|
|
796
|
+
};
|
|
797
|
+
const assigned = (value: string): string | null => {
|
|
798
|
+
const made = mktempPath(value, dir);
|
|
799
|
+
if (made !== null) return made;
|
|
800
|
+
const all = expansions(value, vars, loops).map(homeExpanded);
|
|
801
|
+
return all.length === 1 && unresolvedRefs(all[0]).length === 0 ? all[0] : null;
|
|
802
|
+
};
|
|
803
|
+
for (const segment of segments) {
|
|
804
|
+
if (segment.group === "open") {
|
|
805
|
+
groups.push({ dir, stack: [...stack], vars: new Map(vars), loops: new Map(loops) });
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (segment.group === "close") {
|
|
809
|
+
const saved = groups.pop();
|
|
810
|
+
if (saved) ({ dir, stack, vars, loops } = saved);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const body = commandWords(segment.words);
|
|
814
|
+
const keywords = segment.words.length - body.length;
|
|
815
|
+
for (const keyword of segment.words.slice(0, keywords))
|
|
816
|
+
if (OPENS.has(keyword)) nesting++;
|
|
817
|
+
else if (CLOSES.has(keyword)) nesting = Math.max(0, nesting - 1);
|
|
818
|
+
const loop = segment.words.slice(0, keywords).lastIndexOf("for");
|
|
819
|
+
if (loop >= 0) {
|
|
820
|
+
const [name = "", keyword, ...items] = segment.words.slice(loop + 1);
|
|
821
|
+
const expanding = new Set(segment.expands ?? []);
|
|
822
|
+
const literal =
|
|
823
|
+
keyword === "in" && items.every((_, index) => !expanding.has(loop + 3 + index));
|
|
824
|
+
vars.delete(name);
|
|
825
|
+
if (literal && !untrusted.all && !untrusted.names.has(name))
|
|
826
|
+
loops.set(name, items.map(homeExpanded));
|
|
827
|
+
else loops.delete(name);
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const lead = body.findIndex((word) => !/^\w+=/.test(word));
|
|
831
|
+
const words = lead < 0 ? [] : body.slice(lead);
|
|
832
|
+
if (!segment.conditional && !untrusted.all)
|
|
833
|
+
for (const [key, value] of assignmentsOf(segment.words))
|
|
834
|
+
if (!untrusted.names.has(key)) {
|
|
835
|
+
loops.delete(key);
|
|
836
|
+
const resolved = assigned(value);
|
|
837
|
+
if (resolved === null) vars.delete(key);
|
|
838
|
+
else vars.set(key, resolved);
|
|
839
|
+
}
|
|
840
|
+
const name = (words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
|
|
841
|
+
// A directory change inside a loop or conditional body may or may not run.
|
|
842
|
+
const moves = CD.has(name) || PUSHD.has(name) || POPD.has(name);
|
|
843
|
+
if (moves && strict && nesting > 0) {
|
|
844
|
+
dir = UNKNOWN_DIR;
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (moves && keywords > 0) continue;
|
|
848
|
+
if (CD.has(name)) {
|
|
849
|
+
dir = cdInto(dir, words, strict, vars);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
if (PUSHD.has(name)) {
|
|
853
|
+
stack.push(dir);
|
|
854
|
+
dir = cdInto(dir, words, strict, vars);
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
if (POPD.has(name)) {
|
|
858
|
+
dir = stack.length ? stack.pop() : strict ? UNKNOWN_DIR : undefined;
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
const inner = depth < 4 ? (wrappedScript(words) ?? (strict ? shellScript(words) : null)) : null;
|
|
862
|
+
if (inner !== null && dir !== undefined)
|
|
863
|
+
for (const runIn of workitRunDir(dir, words, atAll))
|
|
864
|
+
targets.push(...segmentTargets(runIn, inner, "posix", depth + 1, strict, vars));
|
|
865
|
+
if (strict && dir !== undefined) {
|
|
866
|
+
const runIn = dir;
|
|
867
|
+
for (const sub of depth < 4 ? (segment.subs ?? []) : [])
|
|
868
|
+
targets.push(...segmentTargets(runIn, sub, dialect, depth + 1, true, vars));
|
|
869
|
+
const expanding = new Set(segment.expands ?? []);
|
|
870
|
+
const scriptOf = (index: number): string | undefined => {
|
|
871
|
+
const word = body[index];
|
|
872
|
+
if (word === undefined || !expanding.has(keywords + index)) return word;
|
|
873
|
+
const all = expansions(word, vars, loops);
|
|
874
|
+
const open = all.length === 1 ? unresolvedRefs(all[0]).filter(unescaped) : [];
|
|
875
|
+
return all.length === 1 && open.length === 0 ? all[0] : undefined;
|
|
876
|
+
};
|
|
877
|
+
const opaque = segment.unanalyzable
|
|
878
|
+
? ["."]
|
|
879
|
+
: inner === null
|
|
880
|
+
? opaqueWrites(body, scriptOf)
|
|
881
|
+
: null;
|
|
882
|
+
for (const file of opaque ?? [])
|
|
883
|
+
for (const resolved of atAll(file))
|
|
884
|
+
targets.push(file === "." ? path.join(resolved, DIR_TARGET) : resolved);
|
|
885
|
+
const ln = words.findIndex((word) => /^ln$/i.test(word.split(/[\\/]/).at(-1) ?? ""));
|
|
886
|
+
if (ln >= 0 && words.slice(0, ln).every((word) => PREFIXES.has(word)))
|
|
887
|
+
targets.push(...linkSources(words.slice(ln + 1), at));
|
|
888
|
+
}
|
|
889
|
+
for (const file of segment.redirects)
|
|
890
|
+
if (!/^\/dev\/(?:null|stdout|stderr|tty)$/.test(file)) targets.push(...atAll(file));
|
|
891
|
+
const edits = editsOf(body);
|
|
892
|
+
if (edits === null) {
|
|
893
|
+
if (dir !== undefined) targets.push(path.join(dir, DIR_TARGET));
|
|
894
|
+
} else for (const file of edits) targets.push(...atAll(file));
|
|
895
|
+
if (dir !== undefined)
|
|
896
|
+
for (const invocation of workitInvocations(body.map(quoteWord).join(" "), "posix")) {
|
|
897
|
+
const { at: runsIn, args } = workitCwd(dir, invocation, atAll);
|
|
898
|
+
if (
|
|
899
|
+
args[0] === "git" &&
|
|
900
|
+
MUTATING_WORKIT_GIT.has(args[1] ?? "") &&
|
|
901
|
+
!inert(args.slice(2), "")
|
|
902
|
+
)
|
|
903
|
+
targets.push(...runsIn.map((runs) => path.join(runs, DIR_TARGET)));
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return targets;
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
/** Where `workit check|lock run [--cwd <dir>] -- <cmd>` runs its command: `--cwd` resolved against `dir`. */
|
|
910
|
+
const workitRunDir = (dir: string, words: string[], resolve: Resolve): string[] => {
|
|
911
|
+
for (const invocation of workitInvocations(words.map(quoteWord).join(" "), "posix")) {
|
|
912
|
+
const { at, args } = workitCwd(dir, invocation, resolve);
|
|
913
|
+
if (args[0] === "check" || (args[0] === "lock" && args[1] === "run")) return at;
|
|
914
|
+
}
|
|
915
|
+
return [dir];
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
/** Resolves a path a command names against a directory: every value it may take. */
|
|
919
|
+
type Resolve = (file: string, base: string) => string[];
|
|
920
|
+
|
|
921
|
+
/** A workit invocation's global `--cwd <dir>` (or `--cwd=<dir>`), resolved against `dir`. */
|
|
922
|
+
const workitCwd = (
|
|
923
|
+
dir: string,
|
|
924
|
+
args: string[],
|
|
925
|
+
resolve: Resolve,
|
|
926
|
+
): { at: string[]; args: string[] } => {
|
|
927
|
+
const { rest, cwd } = skipWorkitGlobals(args);
|
|
928
|
+
return { at: cwd === null ? [dir] : resolve(cwd, dir), args: rest };
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Where one shell command changes files: the files it writes and, as
|
|
933
|
+
* `<dir>/.workit-live-target`, each directory a mutating git or
|
|
934
|
+
* `workit git commit|branch|stage|unstage` runs in or an unknown write happens in.
|
|
935
|
+
* `strict` (an implementer's confinement) also reads `-c` shell scripts and
|
|
936
|
+
* `$(…)`, counts writers the analysis cannot see in the directory they run
|
|
937
|
+
* in, and judges `ln` sources. Absolute paths; [] when the command cannot be
|
|
938
|
+
* analysed (fail open).
|
|
939
|
+
*/
|
|
940
|
+
export const shellMutationTargets = (
|
|
941
|
+
cwd: string,
|
|
942
|
+
command: string,
|
|
943
|
+
dialect: ShellDialect = "posix",
|
|
944
|
+
strict = false,
|
|
945
|
+
): string[] => {
|
|
946
|
+
try {
|
|
947
|
+
return [
|
|
948
|
+
...segmentTargets(cwd, command, dialect, 0, strict),
|
|
949
|
+
...gitMutationDirs(cwd, command, dialect).map((dir) => path.join(dir, DIR_TARGET)),
|
|
950
|
+
];
|
|
951
|
+
} catch {
|
|
952
|
+
return [];
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
/** The command is one simple command led by `WORKIT_LIVE_SESSION=override`. */
|
|
957
|
+
const overridden = (command: string, dialect: ShellDialect): boolean => {
|
|
958
|
+
const segments = segmentsOf(command, dialect);
|
|
959
|
+
if (segments.length !== 1 || segments[0].group || segments[0].subs?.length) return false;
|
|
960
|
+
const words = segments[0].words;
|
|
961
|
+
const lead = words.findIndex((word) => !/^\w+=/.test(word));
|
|
962
|
+
return lead > 0 && words.slice(0, lead).includes(OVERRIDE);
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
const HOST_LABELS: Readonly<Record<string, string>> = {
|
|
966
|
+
claude_code: "Claude Code",
|
|
967
|
+
opencode: "OpenCode",
|
|
968
|
+
codex_cli: "Codex CLI",
|
|
969
|
+
codex_desktop: "Codex Desktop",
|
|
970
|
+
cursor: "Cursor",
|
|
971
|
+
pi: "Pi",
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const ageText = (ms: number): string => {
|
|
975
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
976
|
+
return seconds < 120 ? `${seconds}s` : `${Math.round(seconds / 60)} min`;
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const liveDeny = ({ marker, checkout, ageMs, file }: LiveConflict): HookDecision => {
|
|
980
|
+
const unblock = `Work in your own worktree (\`git worktree add <path> -b <branch>\`, or \`workit fanout worktree create <slice>\`). Only if the user confirms that session is finished: release it with \`rm '${file}'\`, or prefix one command with ${OVERRIDE}.`;
|
|
981
|
+
return {
|
|
982
|
+
kind: "deny",
|
|
983
|
+
reason: `workit live-session guard: ${checkout} belongs to another live agent session, ${HOST_LABELS[marker.host] ?? marker.host} session ${marker.session}${marker.pid === null ? "" : ` (pid ${marker.pid})`}, last active ${ageText(ageMs)} ago. Do not edit a checkout another session is working in; read-only commands stay allowed. ${unblock}`,
|
|
984
|
+
unblock,
|
|
985
|
+
};
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
/** One target per directory. */
|
|
989
|
+
const guarded = (targets: readonly string[]): string[] => {
|
|
990
|
+
const seen = new Set<string>();
|
|
991
|
+
return targets.filter((target) => {
|
|
992
|
+
const dir = path.dirname(target);
|
|
993
|
+
if (seen.has(dir)) return false;
|
|
994
|
+
seen.add(dir);
|
|
995
|
+
return true;
|
|
996
|
+
});
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Deny when a target's checkout belongs to another live session and its
|
|
1001
|
+
* workspace keeps `liveSessionGuard` on. Fails open.
|
|
1002
|
+
*/
|
|
1003
|
+
const liveGate = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps): HookDecision => {
|
|
1004
|
+
try {
|
|
1005
|
+
for (const target of guarded(targets)) {
|
|
1006
|
+
const conflict = liveConflict(target, owner, deps);
|
|
1007
|
+
if (conflict && workspaceLiveSessionGuard(conflict.checkout)) return liveDeny(conflict);
|
|
1008
|
+
}
|
|
1009
|
+
} catch {
|
|
1010
|
+
// Fail open.
|
|
1011
|
+
}
|
|
1012
|
+
return NONE;
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const claimAll = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps): void => {
|
|
1016
|
+
for (const target of guarded(targets)) claimLiveSession(target, owner, deps);
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
/** The absolute paths an edit touches: its files, or cwd itself when unknown. */
|
|
1020
|
+
export const liveTargets = (cwd: string, paths: readonly string[] | null): string[] =>
|
|
1021
|
+
paths?.length ? paths.map((file) => path.resolve(cwd, file)) : [path.join(cwd, DIR_TARGET)];
|
|
1022
|
+
|
|
1023
|
+
// --- the issued-registry guard (#292) ---------------------------------------
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* The files an agent tool call writes under `<state>/issued*`: the issued-id
|
|
1027
|
+
* records and their token hashes. Only the hook and the CLI write there; an
|
|
1028
|
+
* agent that could would forge an issued session or token by hand.
|
|
1029
|
+
*/
|
|
1030
|
+
const issuedTarget = (cwd: string, targets: readonly string[]): string | null => {
|
|
1031
|
+
const state = resolveStateDir();
|
|
1032
|
+
const found = targets.find((target) =>
|
|
1033
|
+
underIssued(state, cwd, homeExpanded(target), process.platform, canonical),
|
|
1034
|
+
);
|
|
1035
|
+
return found ?? null;
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Whether `target` (resolved against `cwd`) is `<state>/issued*` or inside
|
|
1040
|
+
* it. Pure over `platform`, so Windows shapes are testable anywhere: mixed
|
|
1041
|
+
* `\` and `/`, drive-letter case, and the `/c/Users/...` form a POSIX shell
|
|
1042
|
+
* on Windows uses. `real` resolves symlinks and 8.3 short names (RUNNER~1) on
|
|
1043
|
+
* the live platform.
|
|
1044
|
+
*/
|
|
1045
|
+
export const underIssued = (
|
|
1046
|
+
state: string,
|
|
1047
|
+
cwd: string,
|
|
1048
|
+
target: string,
|
|
1049
|
+
platform: NodeJS.Platform = process.platform,
|
|
1050
|
+
real: (file: string) => string = (file) => file,
|
|
1051
|
+
): boolean => {
|
|
1052
|
+
const win = platform === "win32";
|
|
1053
|
+
const p = win ? path.win32 : path.posix;
|
|
1054
|
+
const fold = (value: string) => (win || platform === "darwin" ? value.toLowerCase() : value);
|
|
1055
|
+
const named = win ? target.replace(/^\/([a-zA-Z])(?=\/|$)/, "$1:") : target;
|
|
1056
|
+
const relative = p.relative(fold(real(p.resolve(state))), fold(real(p.resolve(cwd, named))));
|
|
1057
|
+
if (relative.startsWith("..") || p.isAbsolute(relative)) return false;
|
|
1058
|
+
return relative.split(p.sep)[0].startsWith("issued");
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* A POSIX shell drops an unquoted `\` before the analysis sees the words, so
|
|
1063
|
+
* `> C:\state\issued\x` loses its separators. On Windows, a command that
|
|
1064
|
+
* writes and spells out the registry path is denied.
|
|
1065
|
+
*/
|
|
1066
|
+
const unquotedWindowsIssued = (command: string): boolean => {
|
|
1067
|
+
if (process.platform !== "win32" || !command.includes("\\")) return false;
|
|
1068
|
+
const flat = (value: string) => value.replaceAll("\\", "/").toLowerCase();
|
|
1069
|
+
return flat(command).includes(`${flat(resolveStateDir())}/issued`) && shellWrites(command).writes;
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
const issuedDeny = (file: string): HookDecision => {
|
|
1073
|
+
const unblock =
|
|
1074
|
+
"Issued ids and tokens are written only by Workit's SubagentStart hook. Run the verifier or reviewer agent so the host issues its own session; never edit the issued registries by hand.";
|
|
1075
|
+
return {
|
|
1076
|
+
kind: "deny",
|
|
1077
|
+
reason: `workit issued-registry guard: ${file} is under Workit's issued-session state. ${unblock}`,
|
|
1078
|
+
unblock,
|
|
1079
|
+
};
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Deny a working-tree edit while the branch task has unmet before-write
|
|
1084
|
+
* requirements. `paths` are the files the tool writes, or null when unknown
|
|
1085
|
+
* (a shell write, a patch whose headers cannot be read); unknown or empty
|
|
1086
|
+
* targets are gated, and an edit touching only exempt files is always allowed.
|
|
1087
|
+
* With `owner` (the hook's session), the live-session guard runs first, and
|
|
1088
|
+
* an allowed edit refreshes the owner's marker in each checkout it touches.
|
|
1089
|
+
*/
|
|
1090
|
+
export function writeGate(
|
|
1091
|
+
cwd: string,
|
|
1092
|
+
paths: readonly string[] | null,
|
|
1093
|
+
owner?: LiveOwner | null,
|
|
1094
|
+
deps?: LiveDeps,
|
|
1095
|
+
): HookDecision {
|
|
1096
|
+
const forged = paths?.length ? issuedTarget(cwd, paths) : null;
|
|
1097
|
+
if (forged) return issuedDeny(forged);
|
|
1098
|
+
if (!owner) return beforeWriteGate(cwd, paths);
|
|
1099
|
+
const targets = liveTargets(cwd, paths);
|
|
1100
|
+
const live = liveGate(owner, targets, deps);
|
|
1101
|
+
if (live.kind !== "none") return live;
|
|
1102
|
+
const decision = beforeWriteGate(cwd, paths);
|
|
1103
|
+
if (decision.kind === "none") claimAll(owner, targets, deps);
|
|
1104
|
+
return decision;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* The write gates on one shell command: the live-session guard on its writes
|
|
1109
|
+
* and mutating git commands in the directories they run in, then the
|
|
1110
|
+
* before-write gate on its writes. A command that is only
|
|
1111
|
+
* `WORKIT_LIVE_SESSION=override <command>` skips the guard and leaves a
|
|
1112
|
+
* ledger row. An allowed mutation refreshes the owner's marker.
|
|
1113
|
+
*/
|
|
1114
|
+
export function shellGate(
|
|
1115
|
+
cwd: string,
|
|
1116
|
+
command: string,
|
|
1117
|
+
owner: LiveOwner | null,
|
|
1118
|
+
dialect: ShellDialect = "posix",
|
|
1119
|
+
deps?: LiveDeps,
|
|
1120
|
+
): HookDecision {
|
|
1121
|
+
try {
|
|
1122
|
+
const forged =
|
|
1123
|
+
issuedTarget(cwd, segmentTargets(cwd, command, dialect)) ??
|
|
1124
|
+
(unquotedWindowsIssued(command) ? "the issued registry" : null);
|
|
1125
|
+
if (forged) return issuedDeny(forged);
|
|
1126
|
+
} catch {
|
|
1127
|
+
// Fail open, like the other shell analyses here.
|
|
1128
|
+
}
|
|
1129
|
+
const writes = shellWrites(command);
|
|
1130
|
+
let targets: string[] = [];
|
|
1131
|
+
if (owner) targets = shellMutationTargets(cwd, command, dialect);
|
|
1132
|
+
if (owner && targets.length) {
|
|
1133
|
+
const live = liveGate(owner, targets, deps);
|
|
1134
|
+
if (live.kind !== "none") {
|
|
1135
|
+
if (!overridden(command, dialect)) return live;
|
|
1136
|
+
noteOverride(cwd, owner, command);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
const decision = writes.writes ? beforeWriteGate(cwd, writes.targets) : NONE;
|
|
1140
|
+
if (owner && targets.length && decision.kind === "none") claimAll(owner, targets, deps);
|
|
1141
|
+
return decision;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** A ledger row for an override that let a command past the guard (managed repositories only). */
|
|
1145
|
+
const noteOverride = (cwd: string, owner: LiveOwner, command: string): void => {
|
|
1146
|
+
try {
|
|
1147
|
+
if (!inWorkitWorkspace(cwd)) return;
|
|
1148
|
+
appendHookObserved(cwd, {
|
|
1149
|
+
type: "live_session.override",
|
|
1150
|
+
actor: { host: owner.host, session: owner.session, agentId: null },
|
|
1151
|
+
session: owner.session,
|
|
1152
|
+
command: command.slice(0, 200),
|
|
1153
|
+
});
|
|
1154
|
+
} catch {
|
|
1155
|
+
// A note never blocks the command.
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
function beforeWriteGate(cwd: string, paths: readonly string[] | null): HookDecision {
|
|
1160
|
+
try {
|
|
1161
|
+
const gate = gateFor(cwd);
|
|
1162
|
+
if (!gate || gate.blockers.length === 0) return NONE;
|
|
1163
|
+
if (paths?.length && paths.every((file) => exempt(cwd, gate.root, file, gate.plans)))
|
|
1164
|
+
return NONE;
|
|
1165
|
+
const reasons = gate.blockers.map((item) => `${item.ruleId}: ${item.reason}`).join("; ");
|
|
1166
|
+
return {
|
|
1167
|
+
kind: "deny",
|
|
1168
|
+
reason: `workit before-write gate: ${reasons}. Markdown, docs/, plans/ and files outside the checkout stay writable.`,
|
|
1169
|
+
unblock: gate.blockers[0].reason,
|
|
1170
|
+
};
|
|
1171
|
+
} catch {
|
|
1172
|
+
return NONE;
|
|
1173
|
+
}
|
|
1174
|
+
}
|