opencode-worktree-guardian 0.1.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/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/codex/.codex-plugin/plugin.json +31 -0
- package/codex/hooks/guardian-hook.ts +265 -0
- package/codex/hooks/hooks.json +30 -0
- package/codex/skills/worktree-guardian/SKILL.md +29 -0
- package/commands/delete-paths.md +12 -0
- package/commands/delete-worktree.md +16 -0
- package/commands/done.md +14 -0
- package/commands/finish-workflow.md +18 -0
- package/commands/finish.md +16 -0
- package/commands/hygiene.md +16 -0
- package/commands/preserve.md +14 -0
- package/commands/recover.md +14 -0
- package/commands/report.md +14 -0
- package/commands/start.md +14 -0
- package/commands/status.md +14 -0
- package/commands/unblock-finish.md +16 -0
- package/docs/adr/0001-guardian-safety-policy.md +192 -0
- package/docs/publishing.md +55 -0
- package/docs/release-checklist.md +84 -0
- package/package.json +72 -0
- package/scripts/readiness.ts +75 -0
- package/scripts/with-safe-node-temp.mjs +78 -0
- package/skills/worktree-guardian/SKILL.md +73 -0
- package/src/config.ts +87 -0
- package/src/delete-paths-apply.ts +77 -0
- package/src/delete-paths-preflight.ts +146 -0
- package/src/delete-paths.ts +1 -0
- package/src/delete-worktree-preflight.ts +25 -0
- package/src/delete-worktree-report.ts +70 -0
- package/src/delete-worktree-targets.ts +152 -0
- package/src/delete-worktree.ts +222 -0
- package/src/delete.ts +1 -0
- package/src/deletion-fingerprint.ts +59 -0
- package/src/done-primary-publish.ts +129 -0
- package/src/done-primary-snapshot.ts +79 -0
- package/src/done-reattach.ts +32 -0
- package/src/done-shared.ts +28 -0
- package/src/done.ts +80 -0
- package/src/filesystem-boundaries.ts +49 -0
- package/src/finish-dirty-files.ts +56 -0
- package/src/finish-report.ts +80 -0
- package/src/finish.ts +212 -0
- package/src/git.ts +288 -0
- package/src/guards/allowlists.ts +83 -0
- package/src/guards/classifier.ts +39 -0
- package/src/guards/destructive-classifier.ts +189 -0
- package/src/guards/git-invocation.ts +145 -0
- package/src/guards/guard-types.ts +36 -0
- package/src/guards/options.ts +15 -0
- package/src/guards/path-policy.ts +31 -0
- package/src/guards/protected-branch-policy.ts +88 -0
- package/src/guards/shell-parser.ts +126 -0
- package/src/guards/shell-prefix.ts +100 -0
- package/src/guards.ts +3 -0
- package/src/hygiene-apply.ts +230 -0
- package/src/hygiene-scan.ts +200 -0
- package/src/hygiene.ts +10 -0
- package/src/index.ts +18 -0
- package/src/lifecycle.ts +31 -0
- package/src/plugin/direct-file-routing.ts +68 -0
- package/src/plugin/event-log.ts +60 -0
- package/src/plugin/guard-context.ts +40 -0
- package/src/plugin/hook-context.ts +35 -0
- package/src/plugin/invisible-policy.ts +28 -0
- package/src/plugin/native-tool.ts +82 -0
- package/src/plugin/plan-token-cache.ts +66 -0
- package/src/plugin/readable-output-cleanup.ts +141 -0
- package/src/plugin/readable-output-status.ts +86 -0
- package/src/plugin/readable-output-values.ts +21 -0
- package/src/plugin/readable-output-workflow.ts +70 -0
- package/src/plugin/readable-output.ts +16 -0
- package/src/plugin/server.ts +257 -0
- package/src/plugin/session-routing.ts +74 -0
- package/src/plugin/slash-commands.ts +20 -0
- package/src/preserve.ts +32 -0
- package/src/recover.ts +195 -0
- package/src/report.ts +168 -0
- package/src/session/context.ts +41 -0
- package/src/session/last-safe-state.ts +27 -0
- package/src/session/worktree-binding.ts +161 -0
- package/src/start.ts +157 -0
- package/src/state.ts +197 -0
- package/src/tool-registry.ts +35 -0
- package/src/tools.ts +8 -0
- package/src/tui.ts +113 -0
- package/src/types.ts +339 -0
- package/src/unblock-finish.ts +298 -0
- package/src/workflow-candidates.ts +111 -0
- package/src/workflow.ts +84 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { GuardOptions } from "../types.ts";
|
|
3
|
+
import type { CommandPrefix, CommandSegment, GitInvocation } from "./guard-types.ts";
|
|
4
|
+
import { peelCommandPrefix } from "./shell-prefix.ts";
|
|
5
|
+
|
|
6
|
+
const GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--exec-path"]);
|
|
7
|
+
const GIT_PUSH_OPTIONS_WITH_VALUE = new Set(["--repo", "--receive-pack", "--exec", "--push-option", "--recurse-submodules", "-o"]);
|
|
8
|
+
|
|
9
|
+
function peelGitCommandPrefix(segment: CommandSegment): CommandPrefix {
|
|
10
|
+
return peelCommandPrefix(segment);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assignmentMap(assignments: readonly string[]): ReadonlyMap<string, string> {
|
|
14
|
+
const map = new Map<string, string>();
|
|
15
|
+
for (const assignment of assignments) {
|
|
16
|
+
const equals = assignment.indexOf("=");
|
|
17
|
+
if (equals > 0) map.set(assignment.slice(0, equals), assignment.slice(equals + 1));
|
|
18
|
+
}
|
|
19
|
+
return map;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function envConfigAliases(assignments: readonly string[]): string[] {
|
|
23
|
+
const env = assignmentMap(assignments);
|
|
24
|
+
const count = Number(env.get("GIT_CONFIG_COUNT") ?? 0);
|
|
25
|
+
const configs: string[] = [];
|
|
26
|
+
if (!Number.isInteger(count) || count <= 0) return configs;
|
|
27
|
+
for (let index = 0; index < count; index += 1) {
|
|
28
|
+
const key = env.get(`GIT_CONFIG_KEY_${index}`);
|
|
29
|
+
const value = env.get(`GIT_CONFIG_VALUE_${index}`) ?? "";
|
|
30
|
+
if (typeof key === "string") configs.push(`${key}=${value}`);
|
|
31
|
+
}
|
|
32
|
+
return configs;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function hasAliasCapableRuntimeConfig(configs: readonly string[]): boolean {
|
|
36
|
+
return configs.some((config) => {
|
|
37
|
+
const key = config.slice(0, config.indexOf("=")).toLowerCase();
|
|
38
|
+
return key.startsWith("alias.") || key === "include.path" || key.startsWith("includeif.") && key.endsWith(".path");
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseGitInvocation(segment: CommandSegment, options: GuardOptions = {}): GitInvocation | null {
|
|
43
|
+
const { stripped, assignments } = peelGitCommandPrefix(segment);
|
|
44
|
+
if (stripped[0] !== "git") return null;
|
|
45
|
+
let index = 1;
|
|
46
|
+
let gitCwd: string | null = null;
|
|
47
|
+
let workTree: string | null = null;
|
|
48
|
+
const inheritedAssignments = Array.isArray(options.inheritedEnvAssignments) ? options.inheritedEnvAssignments.filter((entry: unknown) => typeof entry === "string") : [];
|
|
49
|
+
const configs: string[] = envConfigAliases([...inheritedAssignments, ...assignments]);
|
|
50
|
+
while (index < stripped.length) {
|
|
51
|
+
const token = stripped[index] ?? "";
|
|
52
|
+
if (!token.startsWith("-")) break;
|
|
53
|
+
if (token === "-C") {
|
|
54
|
+
const nextCwd = stripped[index + 1] ?? null;
|
|
55
|
+
if (nextCwd) {
|
|
56
|
+
gitCwd = gitCwd && !path.isAbsolute(nextCwd) ? path.join(gitCwd, nextCwd) : nextCwd;
|
|
57
|
+
}
|
|
58
|
+
index += 2;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (token === "-c") {
|
|
62
|
+
if (stripped[index + 1]) configs.push(stripped[index + 1] ?? "");
|
|
63
|
+
index += 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (token.startsWith("-c") && token.length > 2) {
|
|
67
|
+
configs.push(token.slice(2));
|
|
68
|
+
index += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (token === "--config-env") {
|
|
72
|
+
if (stripped[index + 1]) configs.push(stripped[index + 1] ?? "");
|
|
73
|
+
index += 2;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (token.startsWith("--config-env=")) {
|
|
77
|
+
configs.push(token.slice("--config-env=".length));
|
|
78
|
+
index += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (token === "--work-tree") {
|
|
82
|
+
workTree = stripped[index + 1] ?? null;
|
|
83
|
+
index += 2;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (token.startsWith("--work-tree=")) {
|
|
87
|
+
workTree = token.slice("--work-tree=".length);
|
|
88
|
+
index += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (GIT_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
|
|
92
|
+
index += 2;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (token.startsWith("--config=")) {
|
|
96
|
+
configs.push(token.slice("--config=".length));
|
|
97
|
+
index += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if ([...GIT_GLOBAL_OPTIONS_WITH_VALUE].some((option) => token.startsWith(`${option}=`))) {
|
|
101
|
+
index += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
index += 1;
|
|
105
|
+
}
|
|
106
|
+
return { subcommand: stripped[index], rest: stripped.slice(index + 1), normalized: stripped, gitCwd, workTree, configs };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function pushRefspecs(rest: CommandSegment): string[] {
|
|
110
|
+
let index = 0;
|
|
111
|
+
let repoOption = false;
|
|
112
|
+
while (index < rest.length) {
|
|
113
|
+
const token = rest[index] ?? "";
|
|
114
|
+
if (token === "--") {
|
|
115
|
+
index += 1;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (!token.startsWith("-")) break;
|
|
119
|
+
if (token === "--repo") {
|
|
120
|
+
repoOption = true;
|
|
121
|
+
index += 2;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (token.startsWith("--repo=")) {
|
|
125
|
+
repoOption = true;
|
|
126
|
+
index += 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (GIT_PUSH_OPTIONS_WITH_VALUE.has(token)) {
|
|
130
|
+
index += 2;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if ([...GIT_PUSH_OPTIONS_WITH_VALUE].some((option) => token.startsWith(`${option}=`))) {
|
|
134
|
+
index += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
index += 1;
|
|
138
|
+
}
|
|
139
|
+
if (!repoOption && index < rest.length) index += 1;
|
|
140
|
+
return rest.slice(index).filter((token) => token && !token.startsWith("-"));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function isForcePushToken(token: string): boolean {
|
|
144
|
+
return token === "--force" || token.startsWith("--force=") || token === "--force-with-lease" || token.startsWith("--force-with-lease=") || token === "-f";
|
|
145
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { GuardDecision } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
export type CommandToken = string;
|
|
4
|
+
export type CommandSegment = readonly CommandToken[];
|
|
5
|
+
export type MutableCommandSegment = CommandToken[];
|
|
6
|
+
export type SegmentSeparator = ";" | "&&" | "||" | "|" | "(" | ")";
|
|
7
|
+
|
|
8
|
+
export type SegmentWithSeparator = {
|
|
9
|
+
readonly segment: CommandSegment;
|
|
10
|
+
readonly nextSeparator: SegmentSeparator | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ShellPayload = {
|
|
14
|
+
readonly payload: string;
|
|
15
|
+
readonly assignments: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CommandPrefix = {
|
|
19
|
+
readonly stripped: CommandSegment;
|
|
20
|
+
readonly assignments: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type GitInvocation = {
|
|
24
|
+
readonly subcommand: string | undefined;
|
|
25
|
+
readonly rest: CommandSegment;
|
|
26
|
+
readonly normalized: CommandSegment;
|
|
27
|
+
readonly gitCwd: string | null;
|
|
28
|
+
readonly workTree: string | null;
|
|
29
|
+
readonly configs: readonly string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type GuardBlockDecision = GuardDecision & {
|
|
33
|
+
readonly blocked: true;
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
readonly segment: CommandSegment;
|
|
36
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { GuardOptions } from "../types.ts";
|
|
2
|
+
import { nonEmptyString, stringArray } from "../types.ts";
|
|
3
|
+
import type { CommandSegment, GuardBlockDecision } from "./guard-types.ts";
|
|
4
|
+
|
|
5
|
+
export function block(reason: string, segment: CommandSegment): GuardBlockDecision {
|
|
6
|
+
return { blocked: true, reason, command: segment.join(" "), segment };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function stringArrayOption(options: GuardOptions, key: keyof GuardOptions): string[] {
|
|
10
|
+
return stringArray(options[key]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function stringOption(options: GuardOptions, key: keyof GuardOptions): string | null {
|
|
14
|
+
return nonEmptyString(options[key]);
|
|
15
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function normalizeForCompare(value: string, cwd: string): string {
|
|
5
|
+
const resolved = path.resolve(cwd, value);
|
|
6
|
+
return path.normalize(resolved);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function realpathForCompare(value: string, cwd: string): string {
|
|
10
|
+
const normalized = normalizeForCompare(value, cwd);
|
|
11
|
+
try {
|
|
12
|
+
return path.normalize(fs.realpathSync.native(normalized));
|
|
13
|
+
} catch {
|
|
14
|
+
return normalized;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isSameOrInside(candidate: string, knownPath: string): boolean {
|
|
19
|
+
const relative = path.relative(knownPath, candidate);
|
|
20
|
+
return relative === "" || Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function pathSameOrInside(candidate: string, target: string, cwd: string): boolean {
|
|
24
|
+
return isSameOrInside(realpathForCompare(candidate, cwd), realpathForCompare(target, cwd));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function matchesKnownWorktreePath(candidate: string, knownWorktreePaths: readonly string[], cwd: string): boolean {
|
|
28
|
+
if (!candidate || candidate.startsWith("-")) return false;
|
|
29
|
+
const resolvedCandidate = normalizeForCompare(candidate, cwd);
|
|
30
|
+
return knownWorktreePaths.some((knownPath) => isSameOrInside(resolvedCandidate, normalizeForCompare(knownPath, cwd)));
|
|
31
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { GuardOptions } from "../types.ts";
|
|
3
|
+
import type { CommandSegment, GuardBlockDecision } from "./guard-types.ts";
|
|
4
|
+
import { hasAliasCapableRuntimeConfig, pushRefspecs } from "./git-invocation.ts";
|
|
5
|
+
import { block, stringArrayOption, stringOption } from "./options.ts";
|
|
6
|
+
import { pathSameOrInside } from "./path-policy.ts";
|
|
7
|
+
|
|
8
|
+
function branchNameFromRef(ref: string): string {
|
|
9
|
+
return ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isProtectedRef(ref: string, protectedBranches: readonly string[]): boolean {
|
|
13
|
+
return protectedBranches.includes(branchNameFromRef(ref));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isGuardianRef(ref: string, branchPrefix: string | null, guardianBranches: readonly string[]): boolean {
|
|
17
|
+
const branch = branchNameFromRef(ref);
|
|
18
|
+
return guardianBranches.includes(branch) || Boolean(branchPrefix && branch.startsWith(branchPrefix));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isProtectedWorktreeTarget(target: string, worktreePaths: readonly string[], cwd: string): boolean {
|
|
22
|
+
return worktreePaths.some((worktreePath) => pathSameOrInside(target, worktreePath, cwd));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function protectedBranchBypass(
|
|
26
|
+
segment: CommandSegment,
|
|
27
|
+
subcommand: string,
|
|
28
|
+
rest: CommandSegment,
|
|
29
|
+
options: GuardOptions,
|
|
30
|
+
gitCwd: string | null,
|
|
31
|
+
workTree: string | null,
|
|
32
|
+
configs: readonly string[],
|
|
33
|
+
): GuardBlockDecision | null {
|
|
34
|
+
const protectedBranches = stringArrayOption(options, "protectedBranches");
|
|
35
|
+
const branchPrefix = stringOption(options, "branchPrefix");
|
|
36
|
+
const guardianBranches = stringArrayOption(options, "guardianBranches");
|
|
37
|
+
const protectedBranchWorktreePaths = stringArrayOption(options, "protectedBranchWorktreePaths");
|
|
38
|
+
if (protectedBranches.length === 0) return null;
|
|
39
|
+
|
|
40
|
+
if (subcommand === "push") {
|
|
41
|
+
const deletesBranch = rest.includes("--delete") || rest.includes("-d");
|
|
42
|
+
for (const rawSpec of pushRefspecs(rest)) {
|
|
43
|
+
const spec = rawSpec.startsWith("+") ? rawSpec.slice(1) : rawSpec;
|
|
44
|
+
const colon = spec.indexOf(":");
|
|
45
|
+
if (colon === -1) {
|
|
46
|
+
if (deletesBranch && isProtectedRef(spec, protectedBranches)) {
|
|
47
|
+
return block("protected branch deletion push is blocked", segment);
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const source = spec.slice(0, colon);
|
|
52
|
+
const target = spec.slice(colon + 1);
|
|
53
|
+
if (!source && target && isProtectedRef(target, protectedBranches)) {
|
|
54
|
+
return block("protected branch deletion push is blocked", segment);
|
|
55
|
+
}
|
|
56
|
+
if (target && isProtectedRef(target, protectedBranches) && (source === "HEAD" || isGuardianRef(source, branchPrefix, guardianBranches))) {
|
|
57
|
+
return block("manual push from Guardian work to a protected branch is blocked; use guardian_finish", segment);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (subcommand === "merge") {
|
|
63
|
+
const currentBranch = stringOption(options, "currentBranch");
|
|
64
|
+
const cwd = stringOption(options, "cwd") ?? process.cwd();
|
|
65
|
+
const gitTargetCwd = gitCwd ? path.resolve(cwd, gitCwd) : null;
|
|
66
|
+
const gitWorkTree = workTree ? path.resolve(gitTargetCwd ?? cwd, workTree) : null;
|
|
67
|
+
const cwdProtected = isProtectedWorktreeTarget(cwd, protectedBranchWorktreePaths, cwd);
|
|
68
|
+
const protectedTarget = gitWorkTree ?? gitTargetCwd;
|
|
69
|
+
const protectedCwd = protectedTarget ? isProtectedWorktreeTarget(protectedTarget, protectedBranchWorktreePaths, cwd) : cwdProtected;
|
|
70
|
+
if (!protectedCwd && (!currentBranch || !isProtectedRef(currentBranch, protectedBranches))) return null;
|
|
71
|
+
const mergeTargets = rest.filter((token) => token && !token.startsWith("-"));
|
|
72
|
+
if (mergeTargets.some((target) => isGuardianRef(target, branchPrefix, guardianBranches))) {
|
|
73
|
+
return block("manual merge of Guardian work into a protected branch is blocked; use guardian_finish", segment);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (hasAliasCapableRuntimeConfig(configs)) {
|
|
78
|
+
const cwd = stringOption(options, "cwd") ?? process.cwd();
|
|
79
|
+
const gitTargetCwd = gitCwd ? path.resolve(cwd, gitCwd) : cwd;
|
|
80
|
+
const gitWorkTree = workTree ? path.resolve(gitTargetCwd, workTree) : null;
|
|
81
|
+
const protectedTarget = gitWorkTree ?? gitTargetCwd;
|
|
82
|
+
if (isProtectedWorktreeTarget(protectedTarget, protectedBranchWorktreePaths, cwd)) {
|
|
83
|
+
return block("runtime git alias-capable config in protected worktrees is blocked; use guardian_finish", segment);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { CommandSegment, MutableCommandSegment, SegmentSeparator, SegmentWithSeparator } from "./guard-types.ts";
|
|
2
|
+
|
|
3
|
+
const SEGMENT_BREAK_VALUES = [";", "&&", "||", "|", "(", ")"] as const;
|
|
4
|
+
export const SEGMENT_BREAKS = new Set<string>(SEGMENT_BREAK_VALUES);
|
|
5
|
+
|
|
6
|
+
export function isSegmentSeparator(token: string): token is SegmentSeparator {
|
|
7
|
+
return SEGMENT_BREAKS.has(token);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function tokenizeCommand(command: string): string[] {
|
|
11
|
+
const tokens: MutableCommandSegment = [];
|
|
12
|
+
let token = "";
|
|
13
|
+
let quote: "'" | "\"" | null = null;
|
|
14
|
+
let escaped = false;
|
|
15
|
+
|
|
16
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
17
|
+
const char = command[index];
|
|
18
|
+
const pair = `${char}${command[index + 1] ?? ""}`;
|
|
19
|
+
if (escaped) {
|
|
20
|
+
token += char;
|
|
21
|
+
escaped = false;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (char === "\\" && quote !== "'") {
|
|
25
|
+
escaped = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if ((char === "'" || char === "\"") && quote === null) {
|
|
29
|
+
quote = char;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (char === quote) {
|
|
33
|
+
quote = null;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (quote === null && (char === "\n" || char === "\r")) {
|
|
37
|
+
if (token) tokens.push(token);
|
|
38
|
+
tokens.push(";");
|
|
39
|
+
token = "";
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (quote === null && /\s/.test(char)) {
|
|
43
|
+
if (token) tokens.push(token);
|
|
44
|
+
token = "";
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (quote === null && (pair === "&&" || pair === "||")) {
|
|
48
|
+
if (token) tokens.push(token);
|
|
49
|
+
tokens.push(pair);
|
|
50
|
+
token = "";
|
|
51
|
+
index += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (quote === null && (char === ";" || char === "|" || char === "(" || char === ")")) {
|
|
55
|
+
if (token) tokens.push(token);
|
|
56
|
+
tokens.push(char);
|
|
57
|
+
token = "";
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (quote === null && char === "$" && command[index + 1] === "(") {
|
|
61
|
+
if (token) tokens.push(token);
|
|
62
|
+
tokens.push("(");
|
|
63
|
+
token = "";
|
|
64
|
+
index += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
token += char;
|
|
68
|
+
}
|
|
69
|
+
if (token) tokens.push(token);
|
|
70
|
+
return tokens;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function commandSegments(tokens: readonly string[]): CommandSegment[] {
|
|
74
|
+
const segments: CommandSegment[] = [];
|
|
75
|
+
let current: MutableCommandSegment = [];
|
|
76
|
+
for (const token of tokens) {
|
|
77
|
+
if (SEGMENT_BREAKS.has(token)) {
|
|
78
|
+
if (current.length) segments.push(current);
|
|
79
|
+
current = [];
|
|
80
|
+
} else {
|
|
81
|
+
current.push(token);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (current.length) segments.push(current);
|
|
85
|
+
return segments;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function commandSegmentsWithSeparators(tokens: readonly string[]): SegmentWithSeparator[] {
|
|
89
|
+
const segments: SegmentWithSeparator[] = [];
|
|
90
|
+
let current: MutableCommandSegment = [];
|
|
91
|
+
for (const token of tokens) {
|
|
92
|
+
if (isSegmentSeparator(token)) {
|
|
93
|
+
if (current.length) segments.push({ segment: current, nextSeparator: token });
|
|
94
|
+
current = [];
|
|
95
|
+
} else {
|
|
96
|
+
current.push(token);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (current.length) segments.push({ segment: current, nextSeparator: null });
|
|
100
|
+
return segments;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function findBacktickPayloads(command: string): string[] {
|
|
104
|
+
const payloads: string[] = [];
|
|
105
|
+
let escaped = false;
|
|
106
|
+
let start = -1;
|
|
107
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
108
|
+
const char = command[index];
|
|
109
|
+
if (escaped) {
|
|
110
|
+
escaped = false;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (char === "\\") {
|
|
114
|
+
escaped = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (char === "`") {
|
|
118
|
+
if (start === -1) start = index + 1;
|
|
119
|
+
else {
|
|
120
|
+
payloads.push(command.slice(start, index));
|
|
121
|
+
start = -1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return payloads;
|
|
126
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import type { CommandPrefix, CommandSegment, MutableCommandSegment, ShellPayload } from "./guard-types.ts";
|
|
3
|
+
import { normalizeForCompare } from "./path-policy.ts";
|
|
4
|
+
import { tokenizeCommand } from "./shell-parser.ts";
|
|
5
|
+
|
|
6
|
+
export const COMMAND_WRAPPERS = new Set(["command", "sudo", "if", "then", "do"]);
|
|
7
|
+
export const SHELL_COMMANDS = new Set(["bash", "sh", "zsh", "dash", "fish"]);
|
|
8
|
+
export const READ_ONLY_SHELL_COMMANDS = new Set(["pwd"]);
|
|
9
|
+
|
|
10
|
+
export function stripCommandWrappers(segment: CommandSegment): CommandSegment {
|
|
11
|
+
let index = 0;
|
|
12
|
+
while (COMMAND_WRAPPERS.has(segment[index] ?? "")) index += 1;
|
|
13
|
+
if (segment[index] === "env") {
|
|
14
|
+
index += 1;
|
|
15
|
+
while (segment[index] && (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(segment[index] ?? "") || (segment[index] ?? "").startsWith("-"))) index += 1;
|
|
16
|
+
}
|
|
17
|
+
return segment.slice(index);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function stripSimpleCommandWrappers(segment: CommandSegment): CommandSegment {
|
|
21
|
+
let index = 0;
|
|
22
|
+
while (COMMAND_WRAPPERS.has(segment[index] ?? "")) index += 1;
|
|
23
|
+
return segment.slice(index);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function peelCommandPrefix(segment: CommandSegment): CommandPrefix {
|
|
27
|
+
let prefixed = stripSimpleCommandWrappers(segment);
|
|
28
|
+
let index = 0;
|
|
29
|
+
const assignments: string[] = [];
|
|
30
|
+
if (prefixed[index] === "env") {
|
|
31
|
+
index += 1;
|
|
32
|
+
while (prefixed[index] && (prefixed[index] ?? "").startsWith("-")) {
|
|
33
|
+
const token = prefixed[index] ?? "";
|
|
34
|
+
if (token === "-S" || token === "--split-string") {
|
|
35
|
+
const split = tokenizeCommand(prefixed[index + 1] ?? "");
|
|
36
|
+
prefixed = [...prefixed.slice(0, index), ...split, ...prefixed.slice(index + 2)];
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (token.startsWith("--split-string=")) {
|
|
40
|
+
const split = tokenizeCommand(token.slice("--split-string=".length));
|
|
41
|
+
prefixed = [...prefixed.slice(0, index), ...split, ...prefixed.slice(index + 1)];
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (token === "-u" || token === "--unset") {
|
|
45
|
+
index += 2;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (token.startsWith("-u") || token.startsWith("--unset=")) {
|
|
49
|
+
index += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
index += 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
while (prefixed[index] && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(prefixed[index] ?? "")) {
|
|
56
|
+
assignments.push(prefixed[index] ?? "");
|
|
57
|
+
index += 1;
|
|
58
|
+
}
|
|
59
|
+
return { stripped: prefixed.slice(index), assignments };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function shellPayload(segment: CommandSegment): ShellPayload | null {
|
|
63
|
+
const { stripped, assignments } = peelCommandPrefix(segment);
|
|
64
|
+
if (!SHELL_COMMANDS.has(stripped[0] ?? "")) return null;
|
|
65
|
+
for (let index = 1; index < stripped.length; index += 1) {
|
|
66
|
+
const token = stripped[index] ?? "";
|
|
67
|
+
if (token === "-c" || token === "-lc" || token === "-cl" || /^-[a-zA-Z]*c[a-zA-Z]*$/.test(token)) {
|
|
68
|
+
const payload = stripped[index + 1] ?? "";
|
|
69
|
+
if (payload.startsWith("\"") || payload.startsWith("'")) {
|
|
70
|
+
const quote = payload[0] ?? "";
|
|
71
|
+
const payloadTokens: string[] = [];
|
|
72
|
+
for (let payloadIndex = index + 1; payloadIndex < stripped.length; payloadIndex += 1) {
|
|
73
|
+
const payloadToken = stripped[payloadIndex] ?? "";
|
|
74
|
+
payloadTokens.push(payloadToken);
|
|
75
|
+
if (payloadToken.length > 1 && payloadToken.endsWith(quote)) break;
|
|
76
|
+
}
|
|
77
|
+
return { payload: payloadTokens.join(" ").replace(new RegExp(`^\\${quote}|\\${quote}$`, "g"), ""), assignments };
|
|
78
|
+
}
|
|
79
|
+
return { payload, assignments };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function cdTarget(segment: CommandSegment, cwd: string): string | null {
|
|
86
|
+
const stripped = stripCommandWrappers(segment);
|
|
87
|
+
if (stripped[0] !== "cd" && stripped[0] !== "pushd") return null;
|
|
88
|
+
const target = stripped.find((token, index) => index > 0 && !token.startsWith("-"));
|
|
89
|
+
if (!target || target === "-") return null;
|
|
90
|
+
const resolved = normalizeForCompare(target, cwd);
|
|
91
|
+
try {
|
|
92
|
+
return fs.statSync(resolved).isDirectory() ? resolved : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function mutableSegment(segment: CommandSegment): MutableCommandSegment {
|
|
99
|
+
return [...segment];
|
|
100
|
+
}
|
package/src/guards.ts
ADDED