projectkeeper 0.0.0-stage → 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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,1665 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-command read and write preflight (Spec §3.1; CKC-03 AC-23; CKC-26 AC-8). Before a
|
|
3
|
+
* command runs it is parsed with a real tokeniser; file reads are checked against the read
|
|
4
|
+
* boundary and obvious project writes are refused. This is a guardrail against a wandering
|
|
5
|
+
* agent drifting out of the project, not an adversarial sandbox: what a command reads at runtime,
|
|
6
|
+
* once the guardrail has let it start, is beyond it — see the report.
|
|
7
|
+
*
|
|
8
|
+
* A place whose path cannot be worked out before the command runs — a variable in a path position,
|
|
9
|
+
* a command substitution, code piped into an interpreter, `eval` — is refused with advice to write
|
|
10
|
+
* the explicit path, rather than passed through unchecked. A `cd` target is itself a read location
|
|
11
|
+
* (later argument-less commands read the new directory), so a `cd` out of the project is refused.
|
|
12
|
+
* Scripts a command runs or sources, and here-document bodies, are read and scanned too. Runtime
|
|
13
|
+
* writes are checked and undone by shell-write-guard.ts. The tokeniser is shared with reads.ts,
|
|
14
|
+
* which works out, after a command ran, what it showed the agent of each file (Spec §1.11).
|
|
15
|
+
*
|
|
16
|
+
* The check also names the places a command says it writes — its redirection targets, the value of
|
|
17
|
+
* an output option (`--output FILE`, `sort -o FILE`), and the operands of the commands that write
|
|
18
|
+
* theirs (tee, cp and mv, rm, touch, mkdir, rmdir, truncate, sed -i) — through `onWrite`. That
|
|
19
|
+
* never changes a decision; on a live project the write guard undoes only those (BQ).
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
22
|
+
import { sep } from 'node:path';
|
|
23
|
+
import { canonicalKey, toAbsolute, type Boundary } from './paths.ts';
|
|
24
|
+
|
|
25
|
+
export interface CommandDecision {
|
|
26
|
+
readonly ok: boolean;
|
|
27
|
+
/** The refusal message for the model, or null when allowed. */
|
|
28
|
+
readonly reason: string | null;
|
|
29
|
+
/** The path or construct the refusal is about, for the step record. */
|
|
30
|
+
readonly detail: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type TokKind = 'word' | 'op';
|
|
34
|
+
export interface Token {
|
|
35
|
+
readonly kind: TokKind;
|
|
36
|
+
readonly text: string;
|
|
37
|
+
/** A word that contains an expansion whose value is not known before the command runs. */
|
|
38
|
+
readonly dynamic: boolean;
|
|
39
|
+
/** A word that contains a command substitution `$(...)` / backtick, or process substitution. */
|
|
40
|
+
readonly substitution: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CONTROL_OPS = new Set([';', '&&', '||', '|', '|&', '&', '\n']);
|
|
44
|
+
const SEPARATORS = new Set([';', '&&', '||', '|', '|&', '&', '\n', '(', ')', ';;']);
|
|
45
|
+
const REDIRECTS = new Set(['<', '>', '>>', '<<', '<<<', '<<-', '&>', '&>>', '2>', '2>>', '1>', '1>>', '0<']);
|
|
46
|
+
/** The working directory can no longer be followed (`cd -`, `popd`); any later argument-less or relative read is refused. */
|
|
47
|
+
const CWD_UNTRACKABLE = 'PK_CWD_UNTRACKABLE';
|
|
48
|
+
const MAX_SCAN_DEPTH = 3;
|
|
49
|
+
const MAX_SCAN_BYTES = 512_000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The system whose shell a command runs in, where reading a command depends on it: how an absolute path is written in
|
|
53
|
+
* free text, which tools the system has. Everything else is read the same everywhere.
|
|
54
|
+
*/
|
|
55
|
+
interface CommandSystem {
|
|
56
|
+
readonly platform: NodeJS.Platform;
|
|
57
|
+
/** Whether a name exists at the file system's root (`/etc`, `/Users`): what makes `/name/…` in free text a path. */
|
|
58
|
+
readonly atRoot: (name: string) => boolean;
|
|
59
|
+
}
|
|
60
|
+
const rootNames = new Map<string, boolean>();
|
|
61
|
+
const THIS_SYSTEM: CommandSystem = {
|
|
62
|
+
platform: process.platform,
|
|
63
|
+
atRoot: (name) => {
|
|
64
|
+
let there = rootNames.get(name);
|
|
65
|
+
if (there === undefined) { there = existsSync(`/${name}`); rootNames.set(name, there); }
|
|
66
|
+
return there;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
let system: CommandSystem = THIS_SYSTEM;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* For tests: read commands as `platform` reads them, with `names` the names at the file system's root, until the
|
|
73
|
+
* function returned is called. Paths are still resolved by the system the test runs on.
|
|
74
|
+
*/
|
|
75
|
+
export function readCommandsAs(platform: NodeJS.Platform, names: readonly string[] = []): () => void {
|
|
76
|
+
const before = system;
|
|
77
|
+
system = { platform, atRoot: (name) => names.includes(name) };
|
|
78
|
+
return () => { system = before; };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Split off here-document bodies so command structure tokenises cleanly, but keep the bodies to scan for paths. */
|
|
82
|
+
export function extractHeredocs(command: string): { stripped: string; bodies: string[] } {
|
|
83
|
+
const lines = command.split('\n');
|
|
84
|
+
const out: string[] = [];
|
|
85
|
+
const bodies: string[] = [];
|
|
86
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
87
|
+
const line = lines[i]!;
|
|
88
|
+
out.push(line);
|
|
89
|
+
const m = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line);
|
|
90
|
+
if (m) {
|
|
91
|
+
const delim = m[2]!;
|
|
92
|
+
const body: string[] = [];
|
|
93
|
+
let j = i + 1;
|
|
94
|
+
while (j < lines.length && lines[j]!.trim() !== delim) { body.push(lines[j]!); j += 1; }
|
|
95
|
+
bodies.push(body.join('\n'));
|
|
96
|
+
i = j; // skip the body and the closing delimiter
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { stripped: out.join('\n'), bodies };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Tokenise a bash-family command line, tracking quoting, escapes, expansions and substitutions. `literalBackslash`: the
|
|
104
|
+
* line is PowerShell's or cmd's, where a backslash escapes nothing and is the separator of a path — read as bash reads
|
|
105
|
+
* it, `C:\Users\someone` lost its backslashes and was no path to the checks that follow.
|
|
106
|
+
*/
|
|
107
|
+
export function tokenize(command: string, literalBackslash = false): Token[] {
|
|
108
|
+
const tokens: Token[] = [];
|
|
109
|
+
let buf = '';
|
|
110
|
+
let has = false;
|
|
111
|
+
let dynamic = false;
|
|
112
|
+
let substitution = false;
|
|
113
|
+
const flush = () => {
|
|
114
|
+
if (has) tokens.push({ kind: 'word', text: buf, dynamic, substitution });
|
|
115
|
+
buf = ''; has = false; dynamic = false; substitution = false;
|
|
116
|
+
};
|
|
117
|
+
const op = (text: string) => { flush(); tokens.push({ kind: 'op', text, dynamic: false, substitution: false }); };
|
|
118
|
+
const s = command;
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < s.length) {
|
|
121
|
+
const c = s[i]!;
|
|
122
|
+
if (c === "'") { // single quotes: literal, no expansion
|
|
123
|
+
has = true;
|
|
124
|
+
const end = s.indexOf("'", i + 1);
|
|
125
|
+
if (end < 0) { buf += s.slice(i + 1); i = s.length; } else { buf += s.slice(i + 1, end); i = end + 1; }
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (c === '"') { // double quotes: only $ ` " \ newline are escaped; other backslashes are literal
|
|
129
|
+
has = true;
|
|
130
|
+
i += 1;
|
|
131
|
+
while (i < s.length && s[i] !== '"') {
|
|
132
|
+
if (!literalBackslash && s[i] === '\\' && '$`"\\\n'.includes(s[i + 1] ?? '')) { buf += s[i + 1]; i += 2; continue; }
|
|
133
|
+
if (s[i] === '`') { substitution = true; buf += s[i]; i += 1; continue; }
|
|
134
|
+
if (s[i] === '$' && s[i + 1] === '(') { substitution = true; buf += s[i]; i += 1; continue; }
|
|
135
|
+
if (s[i] === '$') { dynamic = true; buf += s[i]; i += 1; continue; }
|
|
136
|
+
buf += s[i]; i += 1;
|
|
137
|
+
}
|
|
138
|
+
i += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (c === '\\' && !literalBackslash) { has = true; if (i + 1 < s.length) { buf += s[i + 1]; i += 2; } else i += 1; continue; }
|
|
142
|
+
if (c === '`') { has = true; substitution = true; buf += c; i += 1; continue; }
|
|
143
|
+
if (c === '$' && s[i + 1] === '(') { has = true; substitution = true; buf += c; i += 1; continue; }
|
|
144
|
+
if (c === '$' && s[i + 1] === '{') { has = true; dynamic = true; buf += c; i += 1; continue; }
|
|
145
|
+
if (c === '$' && /[A-Za-z_]/.test(s[i + 1] ?? '')) { has = true; dynamic = true; buf += c; i += 1; continue; }
|
|
146
|
+
if (c === ' ' || c === '\t' || c === '\r') { flush(); i += 1; continue; }
|
|
147
|
+
if (c === '\n') { op('\n'); i += 1; continue; }
|
|
148
|
+
if (c === '(' || c === ')') { op(c); i += 1; continue; }
|
|
149
|
+
// Operators, longest match first.
|
|
150
|
+
let matched = '';
|
|
151
|
+
for (const cand of ['<<-', '<<<', '&>>', '>>', '<<', '&>', '2>>', '2>', '1>>', '1>', '0<', '&&', '||', '|&', ';;', ';', '|', '&', '<', '>']) {
|
|
152
|
+
if (s.startsWith(cand, i)) { matched = cand; break; }
|
|
153
|
+
}
|
|
154
|
+
if (matched) { op(matched); i += matched.length; continue; }
|
|
155
|
+
has = true; buf += c; i += 1;
|
|
156
|
+
}
|
|
157
|
+
flush();
|
|
158
|
+
return tokens;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type Node = { readonly kind: 'cmd'; readonly words: Token[] } | { readonly kind: 'op'; readonly op: string };
|
|
162
|
+
|
|
163
|
+
/** Group tokens into commands and the separators between them; redirection operators stay inside their command. */
|
|
164
|
+
export function toNodes(tokens: readonly Token[]): Node[] {
|
|
165
|
+
const nodes: Node[] = [];
|
|
166
|
+
let cur: Token[] = [];
|
|
167
|
+
const flush = () => { if (cur.length) { nodes.push({ kind: 'cmd', words: cur }); cur = []; } };
|
|
168
|
+
for (const t of tokens) {
|
|
169
|
+
if (t.kind === 'op' && SEPARATORS.has(t.text)) { flush(); nodes.push({ kind: 'op', op: t.text }); }
|
|
170
|
+
else cur.push(t);
|
|
171
|
+
}
|
|
172
|
+
flush();
|
|
173
|
+
return nodes;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const HOW_TO_STAY_IN_BOUNDS =
|
|
177
|
+
'Read only this project: its own scope, its ProjectKeeper assets, the toolchain its config points at, and the session files that belong to it. '
|
|
178
|
+
+ 'To find which of your own records cite a source or an id, use pk_find_references / pk_read_assets — do not look in the file system. '
|
|
179
|
+
+ 'Do not read other projects, other ProjectKeeper assets, or credential stores.';
|
|
180
|
+
|
|
181
|
+
const outOfBounds = (path: string, reason: string): CommandDecision => ({ ok: false, reason, detail: path });
|
|
182
|
+
const undeterminable = (what: string): CommandDecision => ({
|
|
183
|
+
ok: false,
|
|
184
|
+
detail: what,
|
|
185
|
+
reason: `Refused: this command reads through ${what}, which cannot be checked before it runs. Write the explicit path you mean. ${HOW_TO_STAY_IN_BOUNDS}`,
|
|
186
|
+
});
|
|
187
|
+
const refuse = (what: string, why: string): CommandDecision => ({ ok: false, detail: what, reason: `Refused: ${why} ${HOW_TO_STAY_IN_BOUNDS}` });
|
|
188
|
+
const OK: CommandDecision = { ok: true, reason: null, detail: null };
|
|
189
|
+
/** A sentinel meaning "handled, allowed" so callers can tell it from "not handled". */
|
|
190
|
+
const OKnull: CommandDecision = OK;
|
|
191
|
+
|
|
192
|
+
/** A `~`, `~/…` or `~user…` reference: the shell expands it to a home directory, outside the project. */
|
|
193
|
+
function isHomeReference(text: string): boolean {
|
|
194
|
+
return /^~($|[\\/]|[^/\\])/.test(text);
|
|
195
|
+
}
|
|
196
|
+
/** A `~user` / `~user/…` reference: another user's home, which `boundary.decide` cannot resolve (pi leaves ~user literal). */
|
|
197
|
+
function isOtherUserHome(text: string): boolean {
|
|
198
|
+
return /^~[^/\\]/.test(text);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Does the token look like a filesystem path (rather than a plain word, flag or pattern)? */
|
|
202
|
+
function looksLikePath(text: string): boolean {
|
|
203
|
+
if (!text) return false;
|
|
204
|
+
if (/^\\(?:n|r|t|0)$/.test(text)) return false; // an escape sequence (for example a newline), not a file
|
|
205
|
+
if (text.startsWith('-')) return false;
|
|
206
|
+
if (isHomeReference(text)) return true; // home or another user's home
|
|
207
|
+
if (/^\/(?![/])/.test(text)) return true; // POSIX absolute or MSYS /c/...
|
|
208
|
+
if (/^[A-Za-z]:[\\/]/.test(text)) return true; // Windows drive
|
|
209
|
+
if (text.startsWith('\\\\')) return true; // UNC
|
|
210
|
+
if (text === '..' || text === '.' || text.startsWith('./') || text.startsWith('../') || text.startsWith('..\\') || text.startsWith('.\\')) return true;
|
|
211
|
+
if (text.includes('/') || text.includes('\\')) return true; // any relative path with a separator
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What the value of an option of awk, sed or the grep family is: a file the command reads (`file`), the file that holds
|
|
217
|
+
* its program or its patterns (`program-file`), the program or a pattern itself (`program`), other text that is no path
|
|
218
|
+
* (a field separator, a glob, a type name: `text`), or a count (`count`: its value is the next word only when that word
|
|
219
|
+
* is a number, since some implementations take the count attached or not at all).
|
|
220
|
+
*/
|
|
221
|
+
type ValueKind = 'file' | 'program-file' | 'program' | 'text' | 'count';
|
|
222
|
+
interface ProgramCommand {
|
|
223
|
+
/** Short options that take a value — attached (`-fprog.awk`) or the next word — and what the value is. */
|
|
224
|
+
readonly short: Readonly<Record<string, ValueKind>>;
|
|
225
|
+
/** Short options whose value, when there is one, is only ever attached (`sed -i.bak`, `gawk -dvars`): the rest of the word. */
|
|
226
|
+
readonly attachedOnly?: string;
|
|
227
|
+
/** Long options that take a value (`--file=x` or `--file x`), and what it is. */
|
|
228
|
+
readonly long: Readonly<Record<string, ValueKind>>;
|
|
229
|
+
/** Options after which no operand is a pattern, so every operand is a file (`rg --files`, `ack -f`). */
|
|
230
|
+
readonly noProgram?: readonly string[];
|
|
231
|
+
/** Operands of the form `name=value` set a variable (awk) and are not files. */
|
|
232
|
+
readonly assignments?: boolean;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* awk, sed and the grep family take a program or a pattern as their first operand — unless an option gives it — and
|
|
236
|
+
* read the files named by the operands after it. The options that take a value are listed so the operands are counted
|
|
237
|
+
* right; an option listed here that another implementation reads as taking no value could hide a file behind it, so
|
|
238
|
+
* only options that take a value wherever they exist are listed, and the others are read as taking none.
|
|
239
|
+
*/
|
|
240
|
+
// -W is not listed: in mawk `-W exec FILE` makes the word after it the program file, which must stay checked.
|
|
241
|
+
const AWK: ProgramCommand = {
|
|
242
|
+
short: { f: 'program-file', E: 'program-file', e: 'program', i: 'file', l: 'file', F: 'text', v: 'text' },
|
|
243
|
+
attachedOnly: 'dDLop',
|
|
244
|
+
long: { '--file': 'program-file', '--exec': 'program-file', '--source': 'program', '--include': 'file', '--load': 'file', '--field-separator': 'text', '--assign': 'text' },
|
|
245
|
+
assignments: true,
|
|
246
|
+
};
|
|
247
|
+
const SED: ProgramCommand = {
|
|
248
|
+
short: { e: 'program', f: 'program-file' },
|
|
249
|
+
attachedOnly: 'i',
|
|
250
|
+
long: { '--expression': 'program', '--file': 'program-file', '--line-length': 'text' },
|
|
251
|
+
};
|
|
252
|
+
const GREP: ProgramCommand = {
|
|
253
|
+
short: { e: 'program', f: 'program-file', m: 'count', A: 'count', B: 'count', C: 'count', d: 'text', D: 'text' },
|
|
254
|
+
long: {
|
|
255
|
+
'--regexp': 'program', '--file': 'program-file', '--exclude-from': 'file', '--max-count': 'count', '--after-context': 'count', '--before-context': 'count',
|
|
256
|
+
'--devices': 'text', '--directories': 'text', '--exclude': 'text', '--include': 'text', '--exclude-dir': 'text', '--binary-files': 'text', '--label': 'text', '--group-separator': 'text',
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
const RG: ProgramCommand = {
|
|
260
|
+
short: { e: 'program', f: 'program-file', g: 'text', t: 'text', T: 'text', r: 'text', E: 'text', A: 'count', B: 'count', C: 'count', m: 'count', M: 'count', j: 'count', d: 'count' },
|
|
261
|
+
long: {
|
|
262
|
+
'--regexp': 'program', '--file': 'program-file', '--ignore-file': 'file', '--pre': 'file', '--hostname-bin': 'file',
|
|
263
|
+
'--glob': 'text', '--iglob': 'text', '--type': 'text', '--type-not': 'text', '--type-add': 'text', '--type-clear': 'text', '--replace': 'text', '--encoding': 'text',
|
|
264
|
+
'--engine': 'text', '--color': 'text', '--colors': 'text', '--context-separator': 'text', '--field-context-separator': 'text', '--field-match-separator': 'text',
|
|
265
|
+
'--path-separator': 'text', '--pre-glob': 'text', '--sort': 'text', '--sortr': 'text', '--hyperlink-format': 'text', '--generate': 'text', '--max-filesize': 'text',
|
|
266
|
+
'--dfa-size-limit': 'text', '--regex-size-limit': 'text', '--after-context': 'count', '--before-context': 'count', '--context': 'count', '--max-count': 'count',
|
|
267
|
+
'--max-columns': 'count', '--threads': 'count', '--max-depth': 'count',
|
|
268
|
+
},
|
|
269
|
+
noProgram: ['--files', '--type-list', '--generate'],
|
|
270
|
+
};
|
|
271
|
+
const AG: ProgramCommand = {
|
|
272
|
+
short: { g: 'program', G: 'text', p: 'file', m: 'count', A: 'count', B: 'count', C: 'count', W: 'count' },
|
|
273
|
+
long: {
|
|
274
|
+
'--path-to-ignore': 'file', '--pager': 'file', '--file-search-regex': 'text', '--ignore': 'text', '--ignore-dir': 'text', '--color-line-number': 'text',
|
|
275
|
+
'--color-match': 'text', '--color-path': 'text', '--depth': 'count', '--max-count': 'count', '--after': 'count', '--before': 'count', '--context': 'count', '--width': 'count',
|
|
276
|
+
},
|
|
277
|
+
noProgram: ['--list-file-types'],
|
|
278
|
+
};
|
|
279
|
+
const ACK: ProgramCommand = {
|
|
280
|
+
short: { g: 'program', t: 'text', m: 'count', A: 'count', B: 'count', C: 'count' },
|
|
281
|
+
long: {
|
|
282
|
+
'--match': 'program', '--files-from': 'file', '--ackrc': 'file', '--pager': 'file', '--type': 'text', '--ignore-dir': 'text', '--noignore-dir': 'text',
|
|
283
|
+
'--ignore-file': 'text', '--type-add': 'text', '--type-set': 'text', '--type-del': 'text', '--output': 'text', '--range-start': 'text', '--range-end': 'text',
|
|
284
|
+
'--max-count': 'count', '--after-context': 'count', '--before-context': 'count', '--context': 'count',
|
|
285
|
+
},
|
|
286
|
+
noProgram: ['-f'],
|
|
287
|
+
};
|
|
288
|
+
const PROGRAM_COMMANDS: Readonly<Record<string, ProgramCommand>> = {
|
|
289
|
+
awk: AWK, gawk: AWK, mawk: AWK, nawk: AWK, sed: SED, gsed: SED, grep: GREP, egrep: GREP, fgrep: GREP, rg: RG, ag: AG, ack: ACK,
|
|
290
|
+
};
|
|
291
|
+
/** Commands that read files or the current directory; after `cd -` / `popd` their target can no longer be placed. */
|
|
292
|
+
const BASH_FAMILY = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
|
|
293
|
+
const INTERPRETERS: Record<string, { flags: string[]; bash: boolean }> = {
|
|
294
|
+
python: { flags: ['-c'], bash: false }, python2: { flags: ['-c'], bash: false }, python3: { flags: ['-c'], bash: false },
|
|
295
|
+
node: { flags: ['-e', '--eval', '-p', '--print'], bash: false }, nodejs: { flags: ['-e', '--eval'], bash: false }, deno: { flags: ['eval'], bash: false },
|
|
296
|
+
bash: { flags: ['-c'], bash: true }, sh: { flags: ['-c'], bash: true }, zsh: { flags: ['-c'], bash: true }, dash: { flags: ['-c'], bash: true }, ksh: { flags: ['-c'], bash: true },
|
|
297
|
+
perl: { flags: ['-e', '-E'], bash: false }, ruby: { flags: ['-e'], bash: false },
|
|
298
|
+
pwsh: { flags: ['-Command', '-c', '-EncodedCommand'], bash: false }, powershell: { flags: ['-Command', '-c', '-EncodedCommand'], bash: false },
|
|
299
|
+
};
|
|
300
|
+
// Flags whose value names a file/directory that is read (output flags are writes and are left alone, Spec §3.1, D31).
|
|
301
|
+
const PATH_VALUE_FLAGS = new Set(['--directory', '--git-dir', '--work-tree', '-f', '--file', '-I', '--include']);
|
|
302
|
+
// Environment variables whose value is a path the command reads (checked even when the value is relative).
|
|
303
|
+
const PATH_ENV = new Set(['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_CONFIG', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_OBJECT_DIRECTORY', 'GIT_INDEX_FILE', 'CDPATH', 'HOME', 'USERPROFILE', 'XDG_CONFIG_HOME']);
|
|
304
|
+
/** An option whose value is a file or directory the command writes, whatever the command (`--output`, `--outfile`, `--outDir` …). */
|
|
305
|
+
const OUTPUT_OPTION = /^--(?:output|out)(?:[-_]?(?:file|document|dir|directory))?$/i;
|
|
306
|
+
/** The short option that names the output file of these commands (`sort -o FILE`, `curl -o FILE`, `wget -O FILE`). */
|
|
307
|
+
const OUTPUT_SHORT: Readonly<Record<string, string>> = {
|
|
308
|
+
sort: '-o', curl: '-o', gcc: '-o', 'g++': '-o', cc: '-o', 'c++': '-o', clang: '-o', 'clang++': '-o', go: '-o', rustc: '-o', pandoc: '-o', wget: '-O',
|
|
309
|
+
};
|
|
310
|
+
/** Flags that make a command follow symbolic links out of the tree; refused in favour of the non-following form. */
|
|
311
|
+
const FOLLOW_LINK: Record<string, RegExp> = {
|
|
312
|
+
grep: /^--dereference-recursive$|^-[A-Za-qs-z]*R[A-Za-z]*$/, egrep: /^--dereference-recursive$|^-[A-Za-qs-z]*R[A-Za-z]*$/, fgrep: /^--dereference-recursive$|^-[A-Za-qs-z]*R[A-Za-z]*$/,
|
|
313
|
+
rg: /^(--follow|-L)$/, find: /^(-L|-follow|--follow)$/, ls: /^(-L|--dereference)$/,
|
|
314
|
+
cp: /^(-L|--dereference|-H)$/, tar: /^(-h|--dereference)$/, rsync: /^(-L|--copy-links|--copy-unsafe-links|-k|--copy-dirlinks)$/,
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* The same for the tools macOS has (BSD's), beside the ones above: its grep follows every link under `-S` (its `-R`
|
|
318
|
+
* alone does not), its tar under `-L`. An `S` after an option that takes a value (`-eSecret`) is the value's.
|
|
319
|
+
*/
|
|
320
|
+
const BSD_GREP_S = /^-(?![A-Za-z]*[efmABCdD][A-Za-z]*S)[A-Za-z]*S[A-Za-z]*$/;
|
|
321
|
+
const FOLLOW_LINK_BSD: Record<string, RegExp> = { grep: BSD_GREP_S, egrep: BSD_GREP_S, fgrep: BSD_GREP_S, tar: /^-[A-Za-z]*L[A-Za-z]*$/ };
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Commands only macOS has that reach outside the project without naming a path the check could see: AppleScript and
|
|
325
|
+
* the clipboard. Each is refused with why. Spotlight's search (`mdfind`) covers the whole machine unless it is kept to
|
|
326
|
+
* a directory, which is then checked like any other operand. (The keychain's logins are refused with the other
|
|
327
|
+
* programs that print a stored login, on every system: `LOGIN_PRINTERS`.)
|
|
328
|
+
*/
|
|
329
|
+
const MAC_REACHES_OUTSIDE: Readonly<Record<string, string>> = {
|
|
330
|
+
osascript: 'osascript runs AppleScript, which can read any file and drive any application; what it will do cannot be checked before it runs.',
|
|
331
|
+
pbpaste: 'pbpaste reads the clipboard, which is not part of the project.',
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** Base command name without a path or extension (…/usr/bin/python3 → python3). */
|
|
335
|
+
export function commandName(word: string): string {
|
|
336
|
+
const base = word.split(/[\\/]/).pop() ?? word;
|
|
337
|
+
return base.replace(/\.(exe|cmd|bat|ps1)$/i, '');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Home-derived path constructs in inline interpreter code, which resolve to a home directory at runtime. */
|
|
341
|
+
function homeDerivedInCode(code: string): boolean {
|
|
342
|
+
return /Path\.home\s*\(|expanduser|os\.environ|getenv|environ\.get|process\.env|homedir\s*\(|%USERPROFILE%|%HOMEPATH%|%APPDATA%|\$env:(USERPROFILE|HOME|APPDATA|HOMEPATH)|\$HOME\b|~\//i.test(code);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** A token that expands, in the shell, to a home directory through an environment variable. */
|
|
346
|
+
function isEnvHomeReference(text: string): boolean {
|
|
347
|
+
return /%(USERPROFILE|APPDATA|LOCALAPPDATA|HOMEPATH|HOMEDRIVE|HOME)%/i.test(text) || /\$env:(USERPROFILE|APPDATA|LOCALAPPDATA|HOMEPATH|HOME)/i.test(text);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Resolve a token's path against the running directory and check it against the boundary (with the home guards). */
|
|
351
|
+
function checkPath(text: string, cwd: string, boundary: Boundary): CommandDecision | null {
|
|
352
|
+
if (isOtherUserHome(text)) return outOfBounds(text, `Out of the project's read boundary: ${text} is another user's home directory. ${HOW_TO_STAY_IN_BOUNDS}`);
|
|
353
|
+
if (isEnvHomeReference(text)) return outOfBounds(text, `Out of the project's read boundary: ${text} points at a home directory through an environment variable. ${HOW_TO_STAY_IN_BOUNDS}`);
|
|
354
|
+
const d = boundary.decide(text, cwd);
|
|
355
|
+
return d.ok ? null : outOfBounds(text, d.reason ?? 'out of bounds');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Device files every program may name: reading them shows nothing of the machine. */
|
|
359
|
+
const HARMLESS_DEVICE = /^\/dev\/(?:null|zero|stdin|stdout|stderr|tty|u?random|fd\/\d+)$/;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The absolute paths free text names on a system whose paths start at `/` (macOS, Linux): a `/` that starts a word —
|
|
363
|
+
* not one inside a relative path, a URL, a variable's expansion or a glob — followed by a first name that exists at the
|
|
364
|
+
* file system's root (`atRoot`), so `/etc/passwd`, `/Users/sam/x` and `/tmp/out` are paths and `/api/users` or the
|
|
365
|
+
* expression `/^## Plan/` are text. A `file:///…` address is one too. Left out: the interpreter line a script starts
|
|
366
|
+
* with (`#!/usr/bin/env python3`) and the device files every program may name.
|
|
367
|
+
*/
|
|
368
|
+
export function posixAbsolutePaths(text: string, atRoot: (name: string) => boolean): string[] {
|
|
369
|
+
const out: string[] = [];
|
|
370
|
+
// A path ends at white space, a quote, or a character that separates things on a command line or in a list.
|
|
371
|
+
for (const m of text.replace(/^#![^\n]*/, '').matchAll(/file:\/\/\/[^\s'"`]*|(?<![\w.~})\]$*?\\/])\/(?![/\s])[^\s'"`;|&<>(),:]*/g)) {
|
|
372
|
+
if (m[0].startsWith('file:')) { out.push(m[0]); continue; }
|
|
373
|
+
const path = m[0].replace(/[.\]}]+$/, '');
|
|
374
|
+
const first = /^\/([^/]+)/.exec(path)?.[1];
|
|
375
|
+
if (first === undefined || HARMLESS_DEVICE.test(path) || !atRoot(first)) continue;
|
|
376
|
+
out.push(path);
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A home directory named in free text: a `~` where a shell would make one of it, at the start of a word — after white
|
|
383
|
+
* space, a quote, `=` or `:` — up to the separator that follows. A `~` inside a name is the name's own: the short name
|
|
384
|
+
* Windows gives a long one (`LONGNA~1`), a backup's `draft~old`. Taken for a home wherever it stood, `build~1/notes.txt`
|
|
385
|
+
* or a path with a space before its short name was refused as "another user's home directory".
|
|
386
|
+
*/
|
|
387
|
+
const HOME_IN_TEXT = '(?<![\\w.~/\\\\$})\\]*?@%+,#-])~[^\\s\'"`]*';
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Scan free text (a here-document body, a script that is not shell) for absolute or home path references, as the
|
|
391
|
+
* system the command runs on writes them: on Windows a drive path, Git Bash's `/c/…` for one, a network path; on macOS
|
|
392
|
+
* and Linux a path from `/`. (Read with the Windows expression alone, `/etc/passwd` or `/Users/sam/…` in a script was
|
|
393
|
+
* no path at all, and one directory name of a single letter anywhere in a path made the rest of it one.)
|
|
394
|
+
*/
|
|
395
|
+
function scanTextForPaths(text: string, cwd: string, boundary: Boundary): CommandDecision | null {
|
|
396
|
+
const named = system.platform === 'win32'
|
|
397
|
+
? [...text.matchAll(new RegExp(`(?:\\/(?:mnt\\/|cygdrive\\/)?[A-Za-z]\\/[^\\s'"\`]*|[A-Za-z]:[\\\\/][^\\s'"\`]*|\\\\\\\\[^\\s'"\`]+|${HOME_IN_TEXT}[\\\\/][^\\s'"\`]*)`, 'g'))].map((m) => m[0])
|
|
398
|
+
: [...[...text.matchAll(new RegExp(`${HOME_IN_TEXT}\\/[^\\s'"\`]*`, 'g'))].map((m) => m[0]), ...posixAbsolutePaths(text, system.atRoot)];
|
|
399
|
+
for (const path of named) {
|
|
400
|
+
const d = checkPath(path, cwd, boundary);
|
|
401
|
+
if (d) return d;
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Read a script a command runs or sources and check the paths inside it (it may sit in the project but read outside). */
|
|
407
|
+
function scanScriptFile(scriptToken: Token, cwd: string, boundary: Boundary, depth: number, writeRoots: readonly string[], scratchDir?: string, onWrite?: WriteSink): CommandDecision | null {
|
|
408
|
+
if (scriptToken.substitution || scriptToken.dynamic) return undeterminable('a computed script path');
|
|
409
|
+
const bad = checkPath(scriptToken.text, cwd, boundary);
|
|
410
|
+
if (bad) return bad; // the script itself is out of bounds
|
|
411
|
+
if (depth >= MAX_SCAN_DEPTH) return undeterminable('a script nested beyond the inspection limit');
|
|
412
|
+
const abs = toAbsolute(scriptToken.text, cwd);
|
|
413
|
+
let content = '';
|
|
414
|
+
try {
|
|
415
|
+
if (statSync(abs).size > MAX_SCAN_BYTES) return undeterminable('a script too large to inspect');
|
|
416
|
+
content = readFileSync(abs, 'utf8');
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; // a glob, or a script the shell will find missing
|
|
419
|
+
return undeterminable('a script that cannot be inspected before execution');
|
|
420
|
+
}
|
|
421
|
+
const shellLike = /\.(sh|bash|zsh|ksh)$/i.test(scriptToken.text) || !/\.[A-Za-z0-9]+$/.test(scriptToken.text);
|
|
422
|
+
if (!shellLike) return CREDENTIAL_IN_CODE.test(content) ? CREDENTIAL_REFUSAL : scanTextForPaths(content, cwd, boundary);
|
|
423
|
+
const r = checkBashCommand(content, cwd, boundary, depth + 1, writeRoots, scratchDir, onWrite);
|
|
424
|
+
return r.ok ? null : r;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Git's credential commands hand out or change the logins stored on this machine: `git credential fill` prints the
|
|
429
|
+
* password or token the user's credential helper keeps for a host. Nothing that reads a project needs them, so they
|
|
430
|
+
* are refused in every spelling: `git credential …` after any of git's own options, a helper as a subcommand
|
|
431
|
+
* (`git credential-manager`) or as its own program (`git-credential-manager`), and an alias made on the spot, which
|
|
432
|
+
* could name either. Every word of a command is looked at, not only the first, so a program put in front that runs
|
|
433
|
+
* the rest (`env`, `command`, `xargs`, `timeout`, `find -exec`) changes nothing; the commands that only print or
|
|
434
|
+
* search text keep their words. A subcommand the shell computes cannot be told from `credential` and is refused too.
|
|
435
|
+
*/
|
|
436
|
+
const CREDENTIAL_REFUSAL: CommandDecision = {
|
|
437
|
+
ok: false, detail: 'git credential',
|
|
438
|
+
reason: "Refused: this command hands out or changes the logins stored on this machine (git credential, or one of git's credential helpers). Nothing that reads a project needs it.",
|
|
439
|
+
};
|
|
440
|
+
/** The same commands named in code given to an interpreter, or in a script that is not a shell's: a text match, no more. */
|
|
441
|
+
const CREDENTIAL_IN_CODE = /\bgit(?:\.exe)?\b[^A-Za-z0-9\n]{1,12}credential|\bgit-credential\b/i;
|
|
442
|
+
const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--attr-source']);
|
|
443
|
+
|
|
444
|
+
function credentialCommand(argv: readonly Token[]): CommandDecision | null {
|
|
445
|
+
const first = commandName(argv[0]!.text).toLowerCase();
|
|
446
|
+
const end = PROGRAM_COMMANDS[first] || first === 'echo' || first === 'printf' ? 1 : argv.length;
|
|
447
|
+
for (let i = 0; i < end; i += 1) {
|
|
448
|
+
if (argv[i]!.kind !== 'word') continue;
|
|
449
|
+
const name = commandName(argv[i]!.text).toLowerCase();
|
|
450
|
+
if (/^git-credential(?:-[\w-]+)?$/.test(name)) return CREDENTIAL_REFUSAL;
|
|
451
|
+
if (name !== 'git') continue;
|
|
452
|
+
for (let j = i + 1; j < argv.length && argv[j]!.kind === 'word'; j += 1) {
|
|
453
|
+
const word = argv[j]!;
|
|
454
|
+
const alias = word.text === '-c' || word.text === '--config-env' ? argv[j + 1]?.text ?? '' : /^--config-env=(.*)$/.exec(word.text)?.[1] ?? '';
|
|
455
|
+
if (/^alias\./i.test(alias)) return CREDENTIAL_REFUSAL;
|
|
456
|
+
if (GIT_OPTIONS_WITH_VALUE.has(word.text)) { j += 1; continue; }
|
|
457
|
+
if (word.text.startsWith('-')) continue;
|
|
458
|
+
if (word.dynamic || word.substitution) return undeterminable('a computed git subcommand');
|
|
459
|
+
if (/^credential(?:-|$)/i.test(word.text)) return CREDENTIAL_REFUSAL;
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Git's trace switches, and its credential manager's, set for a command — before it (`GIT_TRACE_CURL=1 git …`), through
|
|
468
|
+
* `env`, `export` or `declare`, or as PowerShell's `$env:…`. They print what git sends to a remote, and with
|
|
469
|
+
* `GIT_TRACE_REDACT=0` the authorization header as it is. The shell's environment is given none of them (boundary.ts
|
|
470
|
+
* `shellEnv`); this keeps a command from setting one for itself. The commands that only print or search text keep
|
|
471
|
+
* their words, as above.
|
|
472
|
+
*/
|
|
473
|
+
const TRACE_SWITCH = /(?:^|[^A-Za-z0-9_])(?:GIT_TRACE\w*|GIT_CURL_VERBOSE|GCM_TRACE\w*)=|\$env:(?:GIT_TRACE\w*|GIT_CURL_VERBOSE|GCM_TRACE\w*)\b/i;
|
|
474
|
+
const TRACE_REFUSAL: CommandDecision = {
|
|
475
|
+
ok: false, detail: 'git trace',
|
|
476
|
+
reason: "Refused: git's trace switches (GIT_TRACE…, GIT_CURL_VERBOSE, GCM_TRACE…) can print the login git sends to a remote; a command in this shell cannot set them.",
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
/** `words` is the whole simple command, `start` where its program is, after the assignments in front of it. */
|
|
480
|
+
function traceSwitch(words: readonly Token[], start: number): CommandDecision | null {
|
|
481
|
+
const name = commandName(words[start]?.text ?? '').toLowerCase();
|
|
482
|
+
const end = PROGRAM_COMMANDS[name] || name === 'echo' || name === 'printf' ? start : words.length;
|
|
483
|
+
return words.slice(0, end).some((w) => w.kind === 'word' && TRACE_SWITCH.test(w.text)) ? TRACE_REFUSAL : null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ───────────────────────── programs that print a login stored on this machine ─────────────────────────
|
|
487
|
+
//
|
|
488
|
+
// One row for each: the program's name (without its directory or `.exe` / `.cmd`), and what its arguments, lower-cased
|
|
489
|
+
// and joined by one space, look like when it is asked for the login. The list is by name and can miss: a tool that is
|
|
490
|
+
// not here, or one that is renamed, passes. It is not meant to cover every command line of every service.
|
|
491
|
+
const LOGIN_PRINTERS: readonly (readonly [program: RegExp, args: RegExp])[] = [
|
|
492
|
+
// GitHub's and GitLab's command lines: the token itself, or the status with the token shown.
|
|
493
|
+
[/^gh$/, /^auth (?:token\b|status\b.* (?:--show-token|-t)(?: |$))/],
|
|
494
|
+
[/^glab$/, /^(?:auth (?:token\b|status\b.* (?:--show-token|-t)(?: |$))|config get (?:.* )?token\b)/],
|
|
495
|
+
// Cloud command lines: an access token of the signed-in account, or its keys.
|
|
496
|
+
[/^az$/, /(?:^| )account get-access-token\b/],
|
|
497
|
+
[/^gcloud$/, /(?:^| )auth (?:application-default )?print-(?:access|identity|refresh)-token\b/],
|
|
498
|
+
[/^aws$/, /(?:^| )(?:configure export-credentials|sts get-session-token)\b/],
|
|
499
|
+
// Registries: Docker's credential helpers, npm's tokens and the auth keys of its configuration.
|
|
500
|
+
[/^docker-credential-[\w-]+$/, /^(?:get|list)\b/],
|
|
501
|
+
[/^p?npm$/, /^(?:token\b|(?:(?:config|c) )?get\b.*(?:_authtoken|_auth|_password)\b)/],
|
|
502
|
+
// Windows: the stored logins of the user (listed, added or deleted).
|
|
503
|
+
[/^cmdkey$/, /^/],
|
|
504
|
+
// macOS: the keychain.
|
|
505
|
+
[/^security$/, /(?:^| )(?:find-generic-password|find-internet-password|dump-keychain)\b/],
|
|
506
|
+
];
|
|
507
|
+
const LOGIN_PRINTER_REFUSAL: CommandDecision = {
|
|
508
|
+
ok: false, detail: 'a stored login',
|
|
509
|
+
reason: 'Refused: this command prints or changes a login stored on this machine (a token, a password, the keys of an account). Nothing that reads a project needs it.',
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Whether one of those programs, asked for the login, is among `words`. Every word is tried as the program, like
|
|
514
|
+
* `git credential` above, so a program in front that runs the rest changes nothing; after a command that only prints
|
|
515
|
+
* or searches text, only the first is.
|
|
516
|
+
*/
|
|
517
|
+
function printsLogin(words: readonly string[]): boolean {
|
|
518
|
+
const first = commandName(words[0] ?? '').toLowerCase();
|
|
519
|
+
const end = PROGRAM_COMMANDS[first] || first === 'echo' || first === 'printf' ? 1 : words.length;
|
|
520
|
+
for (let i = 0; i < end; i += 1) {
|
|
521
|
+
const program = commandName(words[i]!).toLowerCase();
|
|
522
|
+
const args = words.slice(i + 1, i + 13).join(' ').toLowerCase();
|
|
523
|
+
if (LOGIN_PRINTERS.some(([name, asked]) => name.test(program) && asked.test(args))) return true;
|
|
524
|
+
}
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
/** The words of code given to an interpreter on the command line, its punctuation taken for spaces. */
|
|
528
|
+
const wordsOfCode = (code: string): string[] => code.split(/[^\w/:.@=-]+/).filter(Boolean);
|
|
529
|
+
|
|
530
|
+
/** `git config` reads global and system scopes unless kept to the repository; that reaches the home gitconfig. */
|
|
531
|
+
function checkGitConfig(argv: Token[], cwd: string, boundary: Boundary): CommandDecision | null {
|
|
532
|
+
let local = false;
|
|
533
|
+
let reads = false;
|
|
534
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
535
|
+
const t = argv[i]!.text;
|
|
536
|
+
if (t === '--global' || t === '--system') return refuse(t, `git config ${t} reads outside the repository (the home or system gitconfig).`);
|
|
537
|
+
if (t === '--local' || t === '--worktree') local = true;
|
|
538
|
+
if (t === '--list' || t === '-l' || /^--get/.test(t)) reads = true;
|
|
539
|
+
if (t === '--file' || t === '-f') {
|
|
540
|
+
const val = argv[i + 1];
|
|
541
|
+
if (!val) continue;
|
|
542
|
+
if (val.substitution || val.dynamic) return undeterminable('a computed git config file');
|
|
543
|
+
const d = checkPath(val.text, cwd, boundary);
|
|
544
|
+
return d ?? OKnull; // an explicit file (in or out of bounds) settles it
|
|
545
|
+
}
|
|
546
|
+
const eq = /^(?:--file|-f)=(.+)$/.exec(t);
|
|
547
|
+
if (eq) return checkPath(eq[1]!, cwd, boundary) ?? OKnull;
|
|
548
|
+
}
|
|
549
|
+
const nonFlags = argv.slice(2).filter((w) => !w.text.startsWith('-'));
|
|
550
|
+
const isRead = reads || nonFlags.length <= 1; // --list/--get, or a bare `git config <key>` get
|
|
551
|
+
if (isRead && !local) return refuse('git config', 'git config without --local reads the global and system scopes (the home gitconfig); use --local or --file <in-project>.');
|
|
552
|
+
return OKnull;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Told each place a command says it writes, as written and the directory it is resolved against. */
|
|
556
|
+
export type WriteSink = (path: string, cwd: string) => void;
|
|
557
|
+
|
|
558
|
+
interface CommandOptions {
|
|
559
|
+
readonly pipedInto: boolean;
|
|
560
|
+
readonly prevCmd: { readonly words: Token[]; readonly cwd: string } | null;
|
|
561
|
+
readonly depth: number;
|
|
562
|
+
readonly writeRoots: readonly string[];
|
|
563
|
+
readonly scratchDir?: string;
|
|
564
|
+
/** Named the places the command says it writes (see the header); it never changes a decision. */
|
|
565
|
+
readonly onWrite?: WriteSink;
|
|
566
|
+
/** The command is PowerShell's (or cmd's), not a bash-family shell's. */
|
|
567
|
+
readonly powershell?: boolean;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* The value of an output option this word gives, when there is one: `--output=FILE` or `--output FILE` (and the other
|
|
572
|
+
* spellings OUTPUT_OPTION knows) for any command, the output option of the commands in OUTPUT_SHORT, dd's `of=FILE`.
|
|
573
|
+
*/
|
|
574
|
+
function outputTarget(name: string, word: Token, next: Token | undefined): string | null {
|
|
575
|
+
if (word.dynamic || word.substitution) return null;
|
|
576
|
+
const eq = /^(--[\w-]+)=(.+)$/.exec(word.text);
|
|
577
|
+
if (eq && OUTPUT_OPTION.test(eq[1]!)) return eq[2]!;
|
|
578
|
+
if (name === 'dd') return /^of=(.+)$/.exec(word.text)?.[1] ?? null;
|
|
579
|
+
const takesNext = OUTPUT_OPTION.test(word.text) || OUTPUT_SHORT[name] === word.text;
|
|
580
|
+
return takesNext && next?.kind === 'word' && !next.dynamic && !next.substitution && !next.text.startsWith('-') ? next.text : null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const inWriteRoot = (path: string, cwd: string, roots: readonly string[]): boolean => {
|
|
584
|
+
const key = canonicalKey(path, cwd);
|
|
585
|
+
return roots.some((root) => key === root || key.startsWith(root.endsWith(sep) ? root : root + sep));
|
|
586
|
+
};
|
|
587
|
+
const projectWrite = (path: string): CommandDecision => ({
|
|
588
|
+
ok: false, detail: path,
|
|
589
|
+
reason: `Refused: shell cannot write project files (${path}). Use the job's scratch directory for temporary files; use pk_* tools for the ProjectKeeper folder.`,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
/** The shell expands these three variables to this job's scratch directory (`$env:TMPDIR` in PowerShell), also as the value of an option (`--output="$TMPDIR/x"`). Other expansions stay unknown. */
|
|
593
|
+
function knownScratch(tokens: Token[], scratchDir?: string): Token[] {
|
|
594
|
+
if (!scratchDir) return tokens;
|
|
595
|
+
return tokens.map((token) => {
|
|
596
|
+
if (token.kind !== 'word' || !token.dynamic || token.substitution) return token;
|
|
597
|
+
const m = /^(-{1,2}[\w-]+=)?\$(?:env:)?(?:TMPDIR|TEMP|TMP|\{TMPDIR\}|\{TEMP\}|\{TMP\})(?=$|[\\/])/i.exec(token.text);
|
|
598
|
+
return m ? { ...token, text: (m[1] ?? '') + scratchDir + token.text.slice(m[0].length), dynamic: false } : token;
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ───────────────────────── git: the subcommands that only read ─────────────────────────
|
|
603
|
+
//
|
|
604
|
+
// The Keeper reads a project, so git runs for it when the subcommand, in the form given, is known to only read the
|
|
605
|
+
// repository, and is refused otherwise: a subcommand that changes the repository, one that reaches a remote, one that
|
|
606
|
+
// has git run another program, and one this check does not know — which is what an alias is, also one the repository's
|
|
607
|
+
// own configuration defines (`alias.st = !rm -rf .`), and a program named `git-<x>` on the PATH. A list of the ones
|
|
608
|
+
// that write, as there was before, lets through whatever it forgot.
|
|
609
|
+
//
|
|
610
|
+
// What the repository's own configuration attaches to reading — a text conversion for `diff`, a filter, an fsmonitor
|
|
611
|
+
// hook — still runs with the reading command, as it does for the owner who set it up; only what a command line asks
|
|
612
|
+
// for is judged here.
|
|
613
|
+
|
|
614
|
+
type GitKind = 'writes' | 'outward' | 'runs' | 'unknown' | 'setting';
|
|
615
|
+
const gitRefusal = (kind: GitKind, what: string): CommandDecision => kind === 'writes' ? projectWrite(what) : {
|
|
616
|
+
ok: false, detail: what,
|
|
617
|
+
reason: kind === 'outward' ? `Refused: ${what} reaches a remote (it sends to it, fetches from it, or asks it over the network). The Keeper's shell reads the project as it is on this machine.`
|
|
618
|
+
: kind === 'runs' ? `Refused: ${what} would have git run another program. The Keeper's shell runs git only to read the repository.`
|
|
619
|
+
: kind === 'setting' ? `Refused: ${what} is not a setting this check knows to be harmless for one git command (a setting can name a program git runs, or another place to read). Leave it out.`
|
|
620
|
+
: `Refused: ${what} is not a git command this check knows to only read; it may be an alias or a program of its own. The Keeper's shell runs git only to read the repository: log, show, diff, status, blame, grep, ls-files, cat-file, rev-parse, for-each-ref and the like.`,
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
/** Subcommands that only read, whatever their options (the options that do more are caught for all of them below). */
|
|
624
|
+
const GIT_READS = new Set([
|
|
625
|
+
'status', 'log', 'show', 'diff', 'blame', 'annotate', 'shortlog', 'whatchanged', 'grep', 'ls-files', 'ls-tree', 'cat-file', 'rev-parse', 'rev-list', 'merge-base', 'name-rev',
|
|
626
|
+
'for-each-ref', 'show-ref', 'show-branch', 'diff-tree', 'diff-index', 'diff-files', 'range-diff', 'cherry', 'check-ignore', 'check-attr', 'check-ref-format',
|
|
627
|
+
'check-mailmap', 'var', 'count-objects', 'verify-pack', 'verify-commit', 'verify-tag', 'version', 'patch-id', 'get-tar-commit-id', 'show-index', 'stripspace', 'column',
|
|
628
|
+
]);
|
|
629
|
+
/** Subcommands that send to a remote, fetch from one or ask one. */
|
|
630
|
+
const GIT_OUTWARD = new Set(['push', 'fetch', 'pull', 'clone', 'ls-remote', 'send-email', 'imap-send', 'request-pull', 'svn', 'p4', 'send-pack', 'fetch-pack', 'http-fetch', 'http-push', 'daemon']);
|
|
631
|
+
/** Subcommands that change the repository or the working tree: named, so the refusal can say so. Any other unknown one is refused as unknown. */
|
|
632
|
+
const GIT_WRITES = new Set([
|
|
633
|
+
'add', 'am', 'apply', 'bisect', 'checkout', 'cherry-pick', 'clean', 'commit', 'gc', 'init', 'maintenance', 'merge', 'mv', 'rebase', 'repack', 'reset', 'restore', 'revert', 'rm', 'stash',
|
|
634
|
+
'switch', 'update-index', 'filter-branch', 'filter-repo', 'update-ref', 'replace', 'prune', 'fast-import', 'pack-refs', 'checkout-index', 'read-tree', 'commit-tree', 'write-tree', 'merge-tree',
|
|
635
|
+
'rerere', 'mktag', 'mktree', 'unpack-objects', 'prune-packed', 'multi-pack-index', 'commit-graph', 'merge-file', 'merge-index', 'stage', 'bugreport', 'diagnose', 'mailsplit', 'mailinfo',
|
|
636
|
+
'quiltimport', 'cvsimport', 'pack-objects', 'index-pack', 'unpack-file', 'update-server-info', 'replay', 'interpret-trailers', 'worktree', 'submodule', 'notes', 'reflog', 'symbolic-ref',
|
|
637
|
+
'sparse-checkout', 'lfs', 'remote', 'branch', 'tag', 'config', 'hash-object', 'bundle', 'format-patch', 'archive', 'fsck', 'describe',
|
|
638
|
+
]);
|
|
639
|
+
/** Subcommands whose work is to run another program. */
|
|
640
|
+
const GIT_RUNS = new Set(['difftool', 'mergetool', 'help', 'web--browse']);
|
|
641
|
+
|
|
642
|
+
/** The operands of a subcommand: its words that are no option and no value of one of `valued` (given apart from it). */
|
|
643
|
+
const gitOperands = (rest: readonly string[], valued: RegExp = /^$/): string[] => rest.filter((w, i) => !w.startsWith('-') && !(i > 0 && valued.test(rest[i - 1]!)));
|
|
644
|
+
/** Whether an option is given; a word that is the value of one of `valued` (given apart from it) is no option. */
|
|
645
|
+
const hasOption = (rest: readonly string[], option: RegExp, valued: RegExp = /^$/): boolean => rest.some((w, i) => w.startsWith('-') && option.test(w) && !(i > 0 && valued.test(rest[i - 1]!)));
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Subcommands that read in some forms and do more in others: what the form given does, null when it only reads. A
|
|
649
|
+
* form that is not recognised as reading counts as writing — the listing forms are the ones spelled out.
|
|
650
|
+
*/
|
|
651
|
+
const GIT_FORMS: Readonly<Record<string, (rest: readonly string[]) => GitKind | null>> = {
|
|
652
|
+
branch: (rest) => {
|
|
653
|
+
if (hasOption(rest, /^(?:-[A-Za-z]*[dDmMcCfut][A-Za-z]*|--delete|--move|--copy|--force|--set-upstream-to(?:=.*)?|--unset-upstream|--track(?:=.*)?|--no-track|--edit-description|--create-reflog|--recurse-submodules)$/, /^(?:--sort|--format)$/)) return 'writes';
|
|
654
|
+
if (hasOption(rest, /^(?:-[A-Za-z]*l[A-Za-z]*|--list|--contains|--no-contains|--merged|--no-merged|--points-at|--show-current)(?:=.*)?$/, /^(?:--sort|--format)$/)) return null;
|
|
655
|
+
return gitOperands(rest, /^(?:--sort|--format|--abbrev)$/).length ? 'writes' : null; // a name with no listing option makes a branch
|
|
656
|
+
},
|
|
657
|
+
tag: (rest) => {
|
|
658
|
+
if (hasOption(rest, /^(?:-[A-Za-z]*[asufdmFe][A-Za-z]*|--annotate|--sign|--local-user(?:=.*)?|--force|--delete|--message(?:=.*)?|--file(?:=.*)?|--edit|--create-reflog|--cleanup(?:=.*)?|--trailer(?:=.*)?)$/, /^(?:--sort|--format)$/)) return 'writes';
|
|
659
|
+
if (hasOption(rest, /^(?:-[A-Za-z]*[lv][A-Za-z]*|-n\d*|--list|--verify|--contains|--no-contains|--merged|--no-merged|--points-at)(?:=.*)?$/, /^(?:--sort|--format)$/)) return null;
|
|
660
|
+
return gitOperands(rest, /^(?:--sort|--format)$/).length ? 'writes' : null;
|
|
661
|
+
},
|
|
662
|
+
remote: (rest) => {
|
|
663
|
+
const [verb] = gitOperands(rest);
|
|
664
|
+
if (verb === undefined || verb === 'get-url') return null;
|
|
665
|
+
if (verb === 'show') return hasOption(rest, /^-n$/) ? null : 'outward'; // `show` asks the remote unless told not to
|
|
666
|
+
return verb === 'update' || verb === 'prune' ? 'outward' : 'writes';
|
|
667
|
+
},
|
|
668
|
+
stash: (rest) => (rest[0] === 'list' || rest[0] === 'show' ? null : 'writes'),
|
|
669
|
+
worktree: (rest) => (rest[0] === 'list' ? null : 'writes'),
|
|
670
|
+
notes: (rest) => { const [verb] = gitOperands(rest, /^--ref$/); return verb === undefined || ['list', 'show', 'get-ref'].includes(verb) ? null : 'writes'; },
|
|
671
|
+
reflog: (rest) => (['expire', 'delete', 'drop'].includes(gitOperands(rest)[0] ?? '') ? 'writes' : null),
|
|
672
|
+
'symbolic-ref': (rest) => (hasOption(rest, /^(?:-d|--delete|-m)$/) || gitOperands(rest).length > 1 ? 'writes' : null),
|
|
673
|
+
bisect: (rest) => (rest[0] === 'log' || rest[0] === 'terms' ? null : 'writes'),
|
|
674
|
+
submodule: (rest) => { const [verb] = gitOperands(rest); return verb === undefined || verb === 'status' || verb === 'summary' ? null : 'writes'; },
|
|
675
|
+
'sparse-checkout': (rest) => (rest[0] === 'list' || rest[0] === 'check-rules' ? null : 'writes'),
|
|
676
|
+
lfs: (rest) => {
|
|
677
|
+
const [verb, ...more] = gitOperands(rest);
|
|
678
|
+
if (['ls-files', 'status', 'env', 'version', 'logs'].includes(verb ?? '')) return null;
|
|
679
|
+
if (verb === 'track') return more.length ? 'writes' : null; // with no pattern it lists
|
|
680
|
+
return ['push', 'pull', 'fetch', 'clone', 'locks', 'lock', 'unlock'].includes(verb ?? '') ? 'outward' : 'writes';
|
|
681
|
+
},
|
|
682
|
+
'hash-object': (rest) => (hasOption(rest, /^(?:-[A-Za-z]*w[A-Za-z]*)$/) ? 'writes' : null),
|
|
683
|
+
bundle: (rest) => (['verify', 'list-heads', 'create'].includes(rest[0] ?? '') ? null : 'writes'), // what `create` writes is judged as a file below
|
|
684
|
+
'format-patch': (rest) => (hasOption(rest, /^(?:--stdout|-o|--output-directory(?:=.*)?)$/) ? null : 'writes'), // with neither it writes into the working tree
|
|
685
|
+
archive: (rest) => (hasOption(rest, /^(?:--remote|--exec)(?:=.*)?$/) ? 'outward' : null),
|
|
686
|
+
fsck: (rest) => (hasOption(rest, /^--lost-found$/) ? 'writes' : null),
|
|
687
|
+
describe: (rest) => (hasOption(rest, /^--(?:dirty|broken)(?:=.*)?$/) ? 'writes' : null), // these refresh the index and write it, whatever the settings
|
|
688
|
+
config: (rest) => {
|
|
689
|
+
const operands = gitOperands(rest, /^(?:--file|-f|--blob|--type|--default|--value|--comment|--url)$/);
|
|
690
|
+
const verb = ['get', 'list', 'set', 'unset', 'rename-section', 'remove-section', 'edit'].includes(operands[0] ?? '') ? operands[0]! : null;
|
|
691
|
+
if (hasOption(rest, /^(?:--add|--unset|--unset-all|--replace-all|--rename-section|--remove-section|--edit|-e)$/)) return 'writes';
|
|
692
|
+
return verb ? (verb === 'get' || verb === 'list' ? null : 'writes') : operands.length > 1 ? 'writes' : null; // `git config KEY VALUE` sets
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
/** A setting given to one git command (`-c section.name=value`): the ones that only change how what is read is shown. */
|
|
697
|
+
function harmlessGitSetting(setting: string): boolean {
|
|
698
|
+
const eq = setting.indexOf('=');
|
|
699
|
+
const key = (eq < 0 ? setting : setting.slice(0, eq)).toLowerCase();
|
|
700
|
+
const value = eq < 0 ? 'true' : setting.slice(eq + 1);
|
|
701
|
+
const parts = key.split('.');
|
|
702
|
+
const section = parts[0]!;
|
|
703
|
+
const name = parts[parts.length - 1]!;
|
|
704
|
+
if (parts.length < 2) return false;
|
|
705
|
+
const off = /^(?:false|no|off|0|)$/i.test(value);
|
|
706
|
+
if (section === 'core') {
|
|
707
|
+
if (name === 'pager') return value === '' || value === 'cat';
|
|
708
|
+
if (name === 'fsmonitor') return off;
|
|
709
|
+
return parts.length === 2 && !['editor', 'sshcommand', 'askpass', 'gitproxy', 'hookspath', 'alternaterefscommand', 'worktree', 'bare', 'attributesfile', 'excludesfile'].includes(name);
|
|
710
|
+
}
|
|
711
|
+
if (section === 'pager') return parts.length === 2 && (off || /^(?:true|yes|on|1)$/i.test(value));
|
|
712
|
+
if (section === 'diff') return parts.length === 2 && !['external', 'tool', 'guitool'].includes(name);
|
|
713
|
+
return ['color', 'log', 'blame', 'grep', 'status', 'column', 'advice', 'i18n', 'format', 'pretty', 'safe', 'user', 'author', 'committer', 'feature', 'index', 'pack'].includes(section);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Variables that name a program git runs, or carry git settings, set for a command: before it, through `env`,
|
|
718
|
+
* `export` or `declare`, or as PowerShell's `$env:…`. A pager of `cat`, or none, is the one that passes: agents write
|
|
719
|
+
* `GIT_PAGER=cat git log` to be sure of plain output.
|
|
720
|
+
*/
|
|
721
|
+
const GIT_RUNNER_ENV = /^(?:\$env:)?(GIT_EXTERNAL_DIFF|GIT_SSH_COMMAND|GIT_SSH|GIT_PAGER|GIT_EDITOR|GIT_SEQUENCE_EDITOR|GIT_ASKPASS|SSH_ASKPASS|GIT_PROXY_COMMAND|GIT_EXEC_PATH|GIT_TEMPLATE_DIR|GIT_CONFIG_COUNT|GIT_CONFIG_KEY_\d+|GIT_CONFIG_VALUE_\d+|GIT_CONFIG_PARAMETERS|PAGER)(?:=(.*))?$/i;
|
|
722
|
+
function gitRunnerSetting(words: readonly Token[], start: number): CommandDecision | null {
|
|
723
|
+
const name = commandName(words[start]?.text ?? '').toLowerCase();
|
|
724
|
+
const end = PROGRAM_COMMANDS[name] || name === 'echo' || name === 'printf' ? start : words.length;
|
|
725
|
+
for (const w of words.slice(0, end)) {
|
|
726
|
+
const m = w.kind === 'word' && (/=/.test(w.text) || /^\$env:/i.test(w.text)) ? GIT_RUNNER_ENV.exec(w.text) : null;
|
|
727
|
+
if (m && !(/^(?:GIT_)?PAGER$/i.test(m[1]!) && (m[2] === '' || m[2] === 'cat'))) return gitRefusal('runs', m[1]!.toUpperCase());
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** What `git …` is asked: the subcommand and where it stands, a refusal when it does more than read, and the files it names. */
|
|
733
|
+
function gitRule(argv: readonly Token[]): { subcommand: string; index: number; refusal: CommandDecision | null; reads: Token[]; writes: Token[] } {
|
|
734
|
+
const nothing = { subcommand: '', index: -1, reads: [] as Token[], writes: [] as Token[] };
|
|
735
|
+
let i = 1;
|
|
736
|
+
for (; i < argv.length; i += 1) {
|
|
737
|
+
const w = argv[i]!;
|
|
738
|
+
const t = w.text;
|
|
739
|
+
if (t === '-c') {
|
|
740
|
+
const setting = argv[i + 1];
|
|
741
|
+
if (!setting || setting.dynamic || setting.substitution || !harmlessGitSetting(setting.text)) return { ...nothing, refusal: gitRefusal('setting', `git -c ${(setting?.text ?? '').split('=')[0]}`) };
|
|
742
|
+
i += 1;
|
|
743
|
+
} else if (/^--config-env(?:=|$)/.test(t)) return { ...nothing, refusal: gitRefusal('setting', 'git --config-env') };
|
|
744
|
+
else if (GIT_OPTIONS_WITH_VALUE.has(t)) i += 1;
|
|
745
|
+
else if (t === '-p' || t === '--paginate') return { ...nothing, refusal: gitRefusal('runs', 'git --paginate (the pager)') };
|
|
746
|
+
else if (/^--exec-path=/.test(t)) return { ...nothing, refusal: gitRefusal('runs', 'git --exec-path=… (git programs from another place)') };
|
|
747
|
+
else if (!t.startsWith('-')) break;
|
|
748
|
+
}
|
|
749
|
+
const word = argv[i];
|
|
750
|
+
if (!word) return { ...nothing, refusal: null }; // `git --version`, `git` alone
|
|
751
|
+
if (word.dynamic || word.substitution) return { ...nothing, refusal: undeterminable('a computed git subcommand') };
|
|
752
|
+
const subcommand = word.text;
|
|
753
|
+
const after = argv.slice(i + 1).filter((w) => w.kind === 'word');
|
|
754
|
+
const rest = after.map((w) => w.text);
|
|
755
|
+
const found = { subcommand, index: i };
|
|
756
|
+
const refuse = (kind: GitKind, what = `git ${subcommand}`) => ({ ...found, refusal: gitRefusal(kind, what), reads: [], writes: [] });
|
|
757
|
+
|
|
758
|
+
if (GIT_OUTWARD.has(subcommand)) return refuse('outward');
|
|
759
|
+
if (GIT_RUNS.has(subcommand)) return refuse('runs');
|
|
760
|
+
const form = GIT_FORMS[subcommand];
|
|
761
|
+
if (!form && !GIT_READS.has(subcommand)) return refuse(GIT_WRITES.has(subcommand) ? 'writes' : 'unknown');
|
|
762
|
+
const does = form ? form(rest) : null;
|
|
763
|
+
if (does) return refuse(does);
|
|
764
|
+
// Options that make a reading command do more.
|
|
765
|
+
if (rest.includes('--help')) return refuse('runs', `git ${subcommand} --help (it opens the manual in another program; -h prints the short help here)`);
|
|
766
|
+
const runs = rest.find((w) => /^(?:--ext-diff|--textconv|--filters)$/.test(w) || (subcommand === 'grep' && /^(?:-O.*|--open-files-in-pager(?:=.*)?)$/.test(w)));
|
|
767
|
+
if (runs) return refuse('runs', `git ${subcommand} ${runs.split('=')[0]}`);
|
|
768
|
+
|
|
769
|
+
// Files a reading command is given outside the repository's own content: read, or written.
|
|
770
|
+
const reads: Token[] = [];
|
|
771
|
+
const writes: Token[] = [];
|
|
772
|
+
const operands = after.filter((w) => !w.text.startsWith('-'));
|
|
773
|
+
if (rest.includes('--no-index') && (subcommand === 'diff' || subcommand === 'grep')) reads.push(...operands.slice(subcommand === 'grep' ? 1 : 0));
|
|
774
|
+
if (subcommand === 'hash-object') reads.push(...operands);
|
|
775
|
+
if (subcommand === 'blame') { const at = rest.indexOf('--contents'); if (at >= 0 && after[at + 1]) reads.push(after[at + 1]!); }
|
|
776
|
+
if (subcommand === 'bundle' && operands[1]) (operands[0]!.text === 'create' ? writes : reads).push(operands[1]);
|
|
777
|
+
const computed = [...reads, ...writes].find((w) => w.dynamic || w.substitution);
|
|
778
|
+
if (computed) return { ...found, refusal: undeterminable('a computed path in git'), reads: [], writes: [] };
|
|
779
|
+
return { ...found, refusal: null, reads, writes };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ───────────────────────── commands that run another command ─────────────────────────
|
|
783
|
+
//
|
|
784
|
+
// Every rule below this section is decided by a command's first word. A command that only runs another one — `env`,
|
|
785
|
+
// `timeout 5`, `xargs`, `find … -exec`, the shell's own `if`, `then`, `do`, `{` — used to be judged by its own first
|
|
786
|
+
// word, and the command it runs by none: `git push` was refused and `env git push` ran. So the command a wrapper runs
|
|
787
|
+
// is found here and judged as a command of its own, by the same rules, as deep as the wrappers go (`checkWrapped`,
|
|
788
|
+
// called at the top of `checkSimpleCommand`, is the one place). To find it the wrapper's own options have to be told
|
|
789
|
+
// from the command: each wrapper's are listed, and an option that is not listed is refused as undeterminable rather
|
|
790
|
+
// than guessed at. What a wrapper adds to the command at run time — the lines `xargs` reads, the file `find` found —
|
|
791
|
+
// stands in it as a computed word, which the rules already refuse wherever a computed word cannot be allowed.
|
|
792
|
+
//
|
|
793
|
+
// The list is by name. A program that runs commands and is not here is judged by its own first word, as before.
|
|
794
|
+
|
|
795
|
+
/** A wrapper whose own words are options (some with a value), then perhaps a fixed number of operands, then the command. */
|
|
796
|
+
interface WrapperOptions {
|
|
797
|
+
/** Options whose value is the next word. */
|
|
798
|
+
readonly valued?: readonly string[];
|
|
799
|
+
/** Options that stand alone, their value attached or none. */
|
|
800
|
+
readonly flags?: RegExp;
|
|
801
|
+
/** Operands of the wrapper's own before the command (`timeout`'s duration). */
|
|
802
|
+
readonly operands?: number;
|
|
803
|
+
/** Options with which the wrapper runs nothing (it describes or lists): it is then judged as an ordinary command. */
|
|
804
|
+
readonly inert?: RegExp;
|
|
805
|
+
/** A word of the shell itself: a `cd` the command makes holds for what follows. */
|
|
806
|
+
readonly sameShell?: boolean;
|
|
807
|
+
}
|
|
808
|
+
const WRAPPERS: Readonly<Record<string, WrapperOptions>> = {
|
|
809
|
+
command: { flags: /^-p$/, inert: /^-[pvV]*[vV][pvV]*$/, sameShell: true },
|
|
810
|
+
builtin: { sameShell: true },
|
|
811
|
+
exec: { flags: /^-[cl]+$/, valued: ['-a'], sameShell: true },
|
|
812
|
+
time: { flags: /^-p$/, sameShell: true },
|
|
813
|
+
nohup: {},
|
|
814
|
+
setsid: { flags: /^(?:-[cfw]+|--ctty|--fork|--wait)$/ },
|
|
815
|
+
nice: { valued: ['-n', '--adjustment'], flags: /^(?:-\d+|-n-?\d+|--adjustment=\S+)$/ },
|
|
816
|
+
timeout: { valued: ['-s', '--signal', '-k', '--kill-after'], flags: /^(?:--foreground|--preserve-status|-v|--verbose|--signal=\S+|--kill-after=\S+|-[sk]\S+)$/, operands: 1 },
|
|
817
|
+
stdbuf: { valued: ['-i', '-o', '-e'], flags: /^(?:-[ioe]\S+|--(?:input|output|error)=\S+)$/ },
|
|
818
|
+
ionice: { valued: ['-c', '-n', '--class', '--classdata'], flags: /^(?:-[cn]\d+|-t|--ignore|--class(?:data)?=\S+)$/, inert: /^(?:-p|--pid|-P|--pgid|-u|--uid)/ },
|
|
819
|
+
caffeinate: { valued: ['-t', '-w'], flags: /^-[disum]+$/ },
|
|
820
|
+
winpty: { flags: /^(?:-X\S+|--mouse|--showkey)$/ },
|
|
821
|
+
busybox: { inert: /^--/ },
|
|
822
|
+
unbuffer: { flags: /^-p$/ },
|
|
823
|
+
chronic: { flags: /^-[ev]+$/ },
|
|
824
|
+
wsl: { valued: ['-d', '--distribution', '-u', '--user', '--cd'], flags: /^(?:-e|--exec|--shell-type=\S+)$/, inert: /^(?:-l|--list|--status|--shutdown|--version|--help|--install|--update|--set-\S+|--export|--import|--unregister|-t|--terminate)/ },
|
|
825
|
+
};
|
|
826
|
+
/** Words of the shell's grammar that stand in front of a command, in bash and in PowerShell. */
|
|
827
|
+
const SHELL_WORDS = new Set(['!', '{', 'if', 'then', 'elif', 'else', 'while', 'until', 'do', 'coproc', 'function']);
|
|
828
|
+
/** Programs refused whatever they are given: they run a command as someone else, or start one nobody waits for. */
|
|
829
|
+
const REFUSED_RUNNERS: Readonly<Record<string, string>> = {
|
|
830
|
+
...Object.fromEntries(['sudo', 'doas', 'su', 'runas', 'pkexec', 'runuser', 'chroot'].map((n) => [n, `${n} runs a command as another user or in another root; the Keeper's shell works as the user who started it, in the project.`])),
|
|
831
|
+
...Object.fromEntries(['start', 'start-process', 'saps', 'start-job', 'sajb', 'start-threadjob', 'invoke-wmimethod', 'iwmi', 'invoke-cimmethod', 'icim', 'wmic', 'schtasks', 'register-scheduledtask', 'register-scheduledjob']
|
|
832
|
+
.map((n) => [n, `${n} starts a program apart from this command, which can then be neither checked nor undone; run the program itself.`])),
|
|
833
|
+
};
|
|
834
|
+
/** PowerShell's commands that write the files they are given (and cmd's, which PowerShell and `cmd /c` both know). */
|
|
835
|
+
const WRITE_CMDLETS = new Set([
|
|
836
|
+
'remove-item', 'ri', 'del', 'erase', 'rd', 'set-content', 'sc', 'add-content', 'ac', 'clear-content', 'clc', 'out-file', 'new-item', 'ni', 'md',
|
|
837
|
+
'rename-item', 'rni', 'ren', 'tee-object', 'export-csv', 'epcsv', 'export-clixml', 'set-itemproperty', 'clear-item', 'cli', 'remove-itemproperty',
|
|
838
|
+
]);
|
|
839
|
+
/** The same, for the ones that write their last operand, or the one after `-Destination`. */
|
|
840
|
+
const COPY_CMDLETS = new Set(['copy-item', 'cpi', 'copy', 'move-item', 'mi', 'move']);
|
|
841
|
+
/** Of these the file is the first operand only; what follows is what to write (`Set-Content FILE VALUE`). */
|
|
842
|
+
const WRITES_FIRST_OPERAND = new Set(['set-content', 'sc', 'add-content', 'ac', 'out-file', 'new-item', 'ni', 'tee-object', 'export-csv', 'epcsv', 'export-clixml', 'set-itemproperty']);
|
|
843
|
+
/** Their parameters whose value is no file. */
|
|
844
|
+
const CMDLET_VALUE_PARAMETER = /^-(?:itemtype|type|value|encoding|filter|include|exclude|delimiter|width|stream|credential|inputobject|erroraction|ea|name)$/i;
|
|
845
|
+
|
|
846
|
+
const wordToken = (text: string, dynamic = false): Token => ({ kind: 'word', text, dynamic, substitution: false });
|
|
847
|
+
/** A word that stands for what a wrapper adds to its command when it runs: nothing is known of it. */
|
|
848
|
+
const ADDED_AT_RUN_TIME = wordToken('$ADDED_AT_RUN_TIME', true);
|
|
849
|
+
const unknownOption = (wrapper: string, option: string): CommandDecision => undeterminable(`${wrapper} with an option this check does not know (${option}), so the command it runs cannot be told from its own words`);
|
|
850
|
+
|
|
851
|
+
/** What a command turns out to run. */
|
|
852
|
+
interface Wrapped {
|
|
853
|
+
/** Refused as it stands. */
|
|
854
|
+
readonly refusal?: CommandDecision;
|
|
855
|
+
/** The commands it runs, each as its words. */
|
|
856
|
+
readonly commands?: readonly (readonly Token[])[];
|
|
857
|
+
/** The command lines it hands to a shell. */
|
|
858
|
+
readonly lines?: readonly { readonly text: string; readonly powershell?: boolean }[];
|
|
859
|
+
/** The files it writes itself. */
|
|
860
|
+
readonly writes?: readonly Token[];
|
|
861
|
+
/** The directory it runs its command in, when it changes it (`env -C`). */
|
|
862
|
+
readonly chdir?: Token;
|
|
863
|
+
/** What is left for the rules below to judge: the wrapper with its own operands, without the command. Null: nothing. */
|
|
864
|
+
readonly own: Token[] | null;
|
|
865
|
+
readonly sameShell?: boolean;
|
|
866
|
+
/** The wrapper reads standard input itself; its command gets none of it. */
|
|
867
|
+
readonly takesInput?: boolean;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** `argv` without its redirections; null when the command is no wrapper. */
|
|
871
|
+
function unwrap(argv: readonly Token[], powershell: boolean): Wrapped | null {
|
|
872
|
+
const head = argv[0]!;
|
|
873
|
+
const name = commandName(head.text).toLowerCase();
|
|
874
|
+
const rest = argv.slice(1);
|
|
875
|
+
const texts = (tokens: readonly Token[]): string => tokens.map((t) => t.text).join(' ');
|
|
876
|
+
// Asked only what it is, a program runs nothing.
|
|
877
|
+
if (rest.length === 1 && /^--(?:help|version)$/.test(rest[0]!.text)) return null;
|
|
878
|
+
|
|
879
|
+
// A command whose name is computed. In PowerShell a variable in front is an expression (`$n -gt 1`), or an
|
|
880
|
+
// assignment whose right side is a command; what `&` calls is refused where `&` is seen (checkPowerShellCommand).
|
|
881
|
+
if (head.dynamic) {
|
|
882
|
+
if (!powershell) return { refusal: undeterminable('a command whose name is computed'), own: null };
|
|
883
|
+
return /^[-+*/%]?=$/.test(rest[0]?.text ?? '') && rest.length > 1 ? { commands: [rest.slice(1)], own: null, sameShell: true } : null;
|
|
884
|
+
}
|
|
885
|
+
// A name in capitals is the same program where the system does not tell them apart (`GIT push`, `RM -rf src`),
|
|
886
|
+
// and a program of git's reached by its own name is the subcommand (`git-lfs push`).
|
|
887
|
+
if (!/[\\/]/.test(head.text) && head.text !== head.text.toLowerCase()) return { commands: [[{ ...head, text: head.text.toLowerCase() }, ...rest]], own: null, sameShell: true };
|
|
888
|
+
const ofGit = /^git-([a-z0-9-]+)$/.exec(name);
|
|
889
|
+
if (ofGit && !/[\\/]/.test(head.text) && !/^credential(?:-|$)/.test(ofGit[1]!)) return { commands: [[wordToken('git'), wordToken(ofGit[1]!), ...rest]], own: null };
|
|
890
|
+
const refused = REFUSED_RUNNERS[name];
|
|
891
|
+
if (refused) return { refusal: refuse(name, refused), own: null };
|
|
892
|
+
|
|
893
|
+
if (SHELL_WORDS.has(name)) {
|
|
894
|
+
// `function f { … }` and `coproc NAME { … }` name something first.
|
|
895
|
+
let inner = name === 'function' || (name === 'coproc' && rest[1]?.text === '{') ? rest.slice(1) : rest;
|
|
896
|
+
if (name === '{' && inner[inner.length - 1]?.text === '}') inner = inner.slice(0, -1);
|
|
897
|
+
return inner.length ? { commands: [inner], own: null, sameShell: true } : null;
|
|
898
|
+
}
|
|
899
|
+
// PowerShell: a script block anywhere in a command (`ForEach-Object { … }`, `Invoke-Command -ScriptBlock { … }`).
|
|
900
|
+
if (powershell) {
|
|
901
|
+
const open = argv.findIndex((w) => w.text === '{');
|
|
902
|
+
if (open > 0) {
|
|
903
|
+
const close = argv.findIndex((w, i) => i > open && w.text === '}');
|
|
904
|
+
const inner = argv.slice(open + 1, close < 0 ? undefined : close);
|
|
905
|
+
const after = close < 0 ? [] : argv.slice(close + 1);
|
|
906
|
+
return { commands: inner.length ? [inner] : [], own: [...argv.slice(0, open), ...after] };
|
|
907
|
+
}
|
|
908
|
+
// What `[Diagnostics.Process]::Start(…)` or a COM shell object starts is as far from this check as Start-Process.
|
|
909
|
+
if (argv.some((w) => /Diagnostics\.Process|WScript\.Shell|Shell\.Application/i.test(w.text))) return { refusal: refuse(name, REFUSED_RUNNERS['start-process']!), own: null };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const spec = WRAPPERS[name];
|
|
913
|
+
if (spec) {
|
|
914
|
+
let i = 0;
|
|
915
|
+
for (; i < rest.length; i += 1) {
|
|
916
|
+
const t = rest[i]!.text;
|
|
917
|
+
if (t === '--') { i += 1; break; }
|
|
918
|
+
if (!t.startsWith('-')) break;
|
|
919
|
+
if (spec.inert?.test(t)) return null;
|
|
920
|
+
if (spec.valued?.includes(t)) { i += 1; continue; }
|
|
921
|
+
if (!spec.flags?.test(t) || rest[i]!.dynamic) return { refusal: unknownOption(name, t), own: null };
|
|
922
|
+
}
|
|
923
|
+
const inner = rest.slice(i + (spec.operands ?? 0));
|
|
924
|
+
return inner.length ? { commands: [inner], own: null, sameShell: spec.sameShell } : null;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (name === 'env') {
|
|
928
|
+
const words = [...rest];
|
|
929
|
+
const assignments: Token[] = [];
|
|
930
|
+
let chdir: Token | undefined;
|
|
931
|
+
let i = 0;
|
|
932
|
+
for (; i < words.length; i += 1) {
|
|
933
|
+
const w = words[i]!;
|
|
934
|
+
const t = w.text;
|
|
935
|
+
if (/^[A-Za-z_]\w*=/.test(t)) { assignments.push(w); continue; }
|
|
936
|
+
if (t === '--' || t === '-') continue;
|
|
937
|
+
if (!t.startsWith('-')) break;
|
|
938
|
+
if (/^(?:-i|-0|-v|--ignore-environment|--null|--debug|-u.+|--unset=.*|--argv0=.*|--(?:block|default|ignore)-signal(?:=.*)?|--list-signal-handling)$/.test(t)) continue;
|
|
939
|
+
if (t === '-u' || t === '--unset' || t === '-a' || t === '--argv0') { i += 1; continue; }
|
|
940
|
+
const dir = t === '-C' || t === '--chdir' ? words[i + 1] : /^(?:--chdir=|-C)(.+)$/.test(t) ? { ...w, text: t.replace(/^(?:--chdir=|-C)/, '') } : undefined;
|
|
941
|
+
if (dir) { chdir = dir; if (dir === words[i + 1]) i += 1; continue; }
|
|
942
|
+
// `-S` gives the rest of the command in one string, which env splits into words itself.
|
|
943
|
+
const split = t === '-S' || t === '--split-string' ? words[i + 1] : /^(?:--split-string=|-S)(.+)$/.test(t) ? { ...w, text: t.replace(/^(?:--split-string=|-S)/, '') } : undefined;
|
|
944
|
+
const parts = split && !split.dynamic && !split.substitution ? tokenize(split.text) : null;
|
|
945
|
+
if (!parts || parts.some((p) => p.kind !== 'word')) return { refusal: unknownOption('env', t), own: null };
|
|
946
|
+
words.splice(i, split === words[i + 1] ? 2 : 1, ...parts);
|
|
947
|
+
i -= 1;
|
|
948
|
+
}
|
|
949
|
+
const inner = words.slice(i);
|
|
950
|
+
return inner.length ? { commands: [[...assignments, ...inner]], chdir, own: null } : null;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (name === 'xargs') {
|
|
954
|
+
let replace: string | null = null;
|
|
955
|
+
let i = 0;
|
|
956
|
+
for (; i < rest.length; i += 1) {
|
|
957
|
+
const t = rest[i]!.text;
|
|
958
|
+
if (t === '--') { i += 1; break; }
|
|
959
|
+
if (!t.startsWith('-')) break;
|
|
960
|
+
if (rest[i]!.dynamic) return { refusal: unknownOption('xargs', t), own: null };
|
|
961
|
+
if (t === '-I') { replace = rest[i + 1]?.text ?? '{}'; i += 1; continue; }
|
|
962
|
+
const attached = /^(?:-I|-i|--replace=)(.+)$/.exec(t);
|
|
963
|
+
if (attached) { replace = attached[1]!; continue; }
|
|
964
|
+
if (t === '-i' || t === '--replace') { replace = '{}'; continue; }
|
|
965
|
+
if (/^(?:-[adELnPs]|--arg-file|--delimiter|--max-args|--max-chars|--max-lines|--max-procs|--process-slot-var)$/.test(t)) { i += 1; continue; }
|
|
966
|
+
if (/^(?:-[0rtpxo]+|-[adEelLnPs].+|--null|--no-run-if-empty|--verbose|--interactive|--exit|--open-tty|--show-limits|--(?:arg-file|delimiter|eof|max-args|max-chars|max-lines|max-procs|process-slot-var)=.*|-[el]|--eof|--max-lines)$/.test(t)) continue;
|
|
967
|
+
return { refusal: unknownOption('xargs', t), own: null };
|
|
968
|
+
}
|
|
969
|
+
const inner = rest.slice(i);
|
|
970
|
+
if (inner.length === 0) return null;
|
|
971
|
+
const marker = replace;
|
|
972
|
+
// The lines xargs reads become words of the command: where the replace string stands, or at the end.
|
|
973
|
+
const command = marker ? inner.map((w) => (w.text.includes(marker) ? { ...w, dynamic: true } : w)) : [...inner, ADDED_AT_RUN_TIME];
|
|
974
|
+
return { commands: [command], own: argv.slice(0, 1 + i), takesInput: true };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (name === 'find') {
|
|
978
|
+
const commands: Token[][] = [];
|
|
979
|
+
const writes: Token[] = [];
|
|
980
|
+
const own: Token[] = [head];
|
|
981
|
+
const firstTest = rest.findIndex((w) => /^[-(!]/.test(w.text));
|
|
982
|
+
const starts = rest.slice(0, firstTest < 0 ? rest.length : firstTest);
|
|
983
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
984
|
+
const t = rest[i]!.text;
|
|
985
|
+
if (/^-(?:exec|execdir|ok|okdir)$/.test(t)) {
|
|
986
|
+
let end = i + 1;
|
|
987
|
+
while (end < rest.length && rest[end]!.text !== ';' && rest[end]!.text !== '+') end += 1;
|
|
988
|
+
// `{}` is the file found.
|
|
989
|
+
const inner = rest.slice(i + 1, end).map((w) => (w.text.includes('{}') ? { ...w, dynamic: true } : w));
|
|
990
|
+
if (inner.length) commands.push(inner);
|
|
991
|
+
i = end;
|
|
992
|
+
} else if (t === '-delete') {
|
|
993
|
+
writes.push(...(starts.length ? starts : [wordToken('.')]));
|
|
994
|
+
} else if (/^-(?:fprint|fprint0|fprintf|fls)$/.test(t) && rest[i + 1]) {
|
|
995
|
+
writes.push(rest[i + 1]!);
|
|
996
|
+
i += 1;
|
|
997
|
+
} else own.push(rest[i]!);
|
|
998
|
+
}
|
|
999
|
+
return commands.length || writes.length ? { commands, writes, own } : null;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (name === 'watch') {
|
|
1003
|
+
let i = 0;
|
|
1004
|
+
for (; i < rest.length; i += 1) {
|
|
1005
|
+
const t = rest[i]!.text;
|
|
1006
|
+
if (t === '--') { i += 1; break; }
|
|
1007
|
+
if (!t.startsWith('-')) break;
|
|
1008
|
+
if (t === '-n' || t === '--interval') { i += 1; continue; }
|
|
1009
|
+
if (!/^(?:-[bcdegtxprw]+|-n\S+|--interval=\S+|--differences(?:=\S+)?|--\w[\w-]*)$/.test(t) || rest[i]!.dynamic) return { refusal: unknownOption('watch', t), own: null };
|
|
1010
|
+
}
|
|
1011
|
+
// watch hands its words, joined, to `sh -c`.
|
|
1012
|
+
return i < rest.length ? { lines: [{ text: texts(rest.slice(i)) }], own: null } : null;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (name === 'parallel') {
|
|
1016
|
+
let i = 0;
|
|
1017
|
+
for (; i < rest.length; i += 1) {
|
|
1018
|
+
const t = rest[i]!.text;
|
|
1019
|
+
if (t === '--') { i += 1; break; }
|
|
1020
|
+
if (!t.startsWith('-')) break;
|
|
1021
|
+
if (/^(?:-j|--jobs|-N|-n|--max-args|-L|-P|--max-procs)$/.test(t)) { i += 1; continue; }
|
|
1022
|
+
if (!/^(?:-k|--keep-order|-q|--quote|-X|-m|-u|--ungroup|--dry-run|--will-cite|--bar|--progress|-0|--null|-[jP]\S+|--jobs=\S+|--halt\S*)$/.test(t) || rest[i]!.dynamic) return { refusal: unknownOption('parallel', t), own: null };
|
|
1023
|
+
}
|
|
1024
|
+
const at = rest.findIndex((w, k) => k >= i && /^:{3,4}\+?$/.test(w.text));
|
|
1025
|
+
const template = rest.slice(i, at < 0 ? undefined : at);
|
|
1026
|
+
const given = at < 0 ? [] : rest.slice(at).filter((w) => !/^:{3,4}\+?$/.test(w.text));
|
|
1027
|
+
// parallel hands a shell the command with each argument put in at `{…}`, or at its end; with no command, the
|
|
1028
|
+
// arguments are the commands.
|
|
1029
|
+
const line = texts(template).replace(/\{[^}\s]*\}/g, '$ADDED_AT_RUN_TIME');
|
|
1030
|
+
const lines = template.length ? [{ text: line === texts(template) ? `${line} $ADDED_AT_RUN_TIME` : line }] : given.map((w) => ({ text: w.text }));
|
|
1031
|
+
return { lines, own: [head, ...(template.length ? given : [])] };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (name === 'cmd') {
|
|
1035
|
+
const at = rest.findIndex((w) => /^\/{1,2}[ck]$/i.test(w.text));
|
|
1036
|
+
if (at < 0 || rest.slice(0, at).some((w) => !/^\/{1,2}[a-z](?::\S*)?$/i.test(w.text))) return null;
|
|
1037
|
+
return at + 1 < rest.length ? { lines: [{ text: texts(rest.slice(at + 1)), powershell: true }], own: null } : null; // cmd's line is read with backslashes as they are, like PowerShell's
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (name === 'powershell' || name === 'pwsh') {
|
|
1041
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1042
|
+
const t = rest[i]!.text.toLowerCase();
|
|
1043
|
+
if (/^-(?:c|co\w*)$/.test(t)) return i + 1 < rest.length ? { lines: [{ text: texts(rest.slice(i + 1)), powershell: true }], own: null } : null;
|
|
1044
|
+
if (/^-(?:e|ec|en\w*)$/.test(t)) return { refusal: undeterminable('an encoded PowerShell command'), own: null };
|
|
1045
|
+
if (/^-(?:f|fi\w*)$/.test(t)) return rest[i + 1] ? { own: [head, rest[i + 1]!] } : null; // a script file: read as one below
|
|
1046
|
+
if (/^-(?:ex\w*|ep|w|wi\w*|inputformat|if|outputformat|of|o|wd|workingdirectory|config\w*|custompipename|settingsfile|settings|version|v|psconsolefile)$/.test(t)) { i += 1; continue; }
|
|
1047
|
+
if (t.startsWith('-')) continue;
|
|
1048
|
+
// No -Command and no -File: Windows PowerShell takes the rest for a command, PowerShell 7 for a script file.
|
|
1049
|
+
return { lines: [{ text: texts(rest.slice(i)), powershell: true }], own: [head, rest[i]!] };
|
|
1050
|
+
}
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// The shell's `trap 'commands' SIGNAL…` and `alias name='commands'` keep a command line for later.
|
|
1055
|
+
if (name === 'trap') {
|
|
1056
|
+
const operands = rest.filter((w) => w.text !== '--');
|
|
1057
|
+
if (operands.length < 2 || /^-[lp]/.test(operands[0]!.text) || operands[0]!.text === '-') return null;
|
|
1058
|
+
return { lines: [{ text: operands[0]!.text }], own: null };
|
|
1059
|
+
}
|
|
1060
|
+
if (name === 'alias') {
|
|
1061
|
+
const lines = rest.flatMap((w) => { const eq = w.text.indexOf('='); return eq > 0 ? [{ text: w.text.slice(eq + 1) }] : []; });
|
|
1062
|
+
return lines.length ? { lines, own: null } : null;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// An interpreter's option letters written together (`bash -lc '…'`, `python -uc '…'`, `perl -ne '…'`), and `--`
|
|
1066
|
+
// before the code: put as the rule for inline code below reads them.
|
|
1067
|
+
const interp = INTERPRETERS[name];
|
|
1068
|
+
if (interp) {
|
|
1069
|
+
const letters = interp.flags.filter((f) => /^-[A-Za-z]$/.test(f)).map((f) => f[1]!);
|
|
1070
|
+
let changed = false;
|
|
1071
|
+
const own: Token[] = [head];
|
|
1072
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1073
|
+
const w = rest[i]!;
|
|
1074
|
+
const cluster = /^-[A-Za-z0-9]+([A-Za-z])$/.exec(w.text);
|
|
1075
|
+
if (cluster && w.text.length > 2 && letters.includes(cluster[1]!) && !interp.flags.includes(w.text)) {
|
|
1076
|
+
own.push(wordToken(`-${cluster[1]}`));
|
|
1077
|
+
if (rest[i + 1]?.text === '--') i += 1;
|
|
1078
|
+
changed = true;
|
|
1079
|
+
} else if (interp.flags.includes(w.text) && rest[i + 1]?.text === '--') {
|
|
1080
|
+
own.push(w);
|
|
1081
|
+
i += 1;
|
|
1082
|
+
changed = true;
|
|
1083
|
+
} else own.push(w);
|
|
1084
|
+
}
|
|
1085
|
+
return changed ? { own } : null;
|
|
1086
|
+
}
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Judge what a command runs, when it runs another: null when it is no wrapper; a refusal; or what is left of the
|
|
1092
|
+
* wrapper for the rules below (`own`, null when nothing is), with the directory the shell is in afterwards.
|
|
1093
|
+
*/
|
|
1094
|
+
function checkWrapped(argv: readonly Token[], cwd: string, boundary: Boundary, opts: CommandOptions): { decision: CommandDecision | null; cwd: string; own: Token[] | null } | null {
|
|
1095
|
+
// Redirections belong to the command that is run in the end; they go with it.
|
|
1096
|
+
const plain: Token[] = [];
|
|
1097
|
+
const redirections: Token[] = [];
|
|
1098
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1099
|
+
if (argv[i]!.kind === 'op' && REDIRECTS.has(argv[i]!.text)) { redirections.push(argv[i]!, ...(argv[i + 1] ? [argv[i + 1]!] : [])); i += 1; } else plain.push(argv[i]!);
|
|
1100
|
+
}
|
|
1101
|
+
if (plain.length === 0) return null;
|
|
1102
|
+
const name = commandName(plain[0]!.text).toLowerCase();
|
|
1103
|
+
const stop = (decision: CommandDecision) => ({ decision, cwd, own: null });
|
|
1104
|
+
|
|
1105
|
+
// An interpreter given its script on standard input (`bash < build.sh`) runs it as `bash build.sh` does.
|
|
1106
|
+
const fed = INTERPRETERS[name] && !plain.slice(1).some((w) => !w.text.startsWith('-')) ? redirections.findIndex((w) => w.kind === 'op' && (w.text === '<' || w.text === '0<')) : -1;
|
|
1107
|
+
if (fed >= 0 && redirections[fed + 1]) {
|
|
1108
|
+
const d = scanScriptFile(redirections[fed + 1]!, cwd, boundary, opts.depth, opts.writeRoots, opts.scratchDir, opts.onWrite);
|
|
1109
|
+
if (d) return stop(d);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// PowerShell's and cmd's commands that write the files they name: said as writes, and refused in the project.
|
|
1113
|
+
if (opts.powershell && (WRITE_CMDLETS.has(name) || COPY_CMDLETS.has(name))) {
|
|
1114
|
+
const operands = plain.slice(1).filter((w, i) => !w.text.startsWith('-') && !CMDLET_VALUE_PARAMETER.test(plain[i]!.text));
|
|
1115
|
+
const positional = operands.filter((w) => !plain[plain.indexOf(w) - 1]!.text.startsWith('-'));
|
|
1116
|
+
const named = plain.findIndex((w) => /^-dest/i.test(w.text));
|
|
1117
|
+
const targets = COPY_CMDLETS.has(name) ? (named >= 0 && plain[named + 1] ? [plain[named + 1]!] : operands.slice(-1))
|
|
1118
|
+
: WRITES_FIRST_OPERAND.has(name) ? [...operands.filter((w) => !positional.includes(w)), ...positional.slice(0, 1)] : operands;
|
|
1119
|
+
for (const target of targets) {
|
|
1120
|
+
if (target.dynamic || target.substitution) return stop(undeterminable('a computed output path'));
|
|
1121
|
+
opts.onWrite?.(target.text, cwd);
|
|
1122
|
+
if (inWriteRoot(target.text, cwd, opts.writeRoots)) return stop(projectWrite(target.text));
|
|
1123
|
+
}
|
|
1124
|
+
if (/^(?:move-item|mi|move)$/.test(name)) for (const source of operands) if (!source.dynamic && !targets.includes(source)) opts.onWrite?.(source.text, cwd);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const wrapped = unwrap(plain, opts.powershell === true);
|
|
1128
|
+
if (!wrapped) return null;
|
|
1129
|
+
if (wrapped.refusal) return stop(wrapped.refusal);
|
|
1130
|
+
let inside = cwd;
|
|
1131
|
+
if (wrapped.chdir) {
|
|
1132
|
+
if (wrapped.chdir.dynamic || wrapped.chdir.substitution) return stop(undeterminable('a computed directory'));
|
|
1133
|
+
const d = checkPath(wrapped.chdir.text, cwd, boundary);
|
|
1134
|
+
if (d) return stop(d);
|
|
1135
|
+
inside = toAbsolute(wrapped.chdir.text, cwd);
|
|
1136
|
+
}
|
|
1137
|
+
for (const target of wrapped.writes ?? []) {
|
|
1138
|
+
if (target.dynamic || target.substitution) return stop(undeterminable('a computed output path'));
|
|
1139
|
+
opts.onWrite?.(target.text, cwd);
|
|
1140
|
+
if (inWriteRoot(target.text, cwd, opts.writeRoots)) return stop(projectWrite(target.text));
|
|
1141
|
+
}
|
|
1142
|
+
for (const line of wrapped.lines ?? []) {
|
|
1143
|
+
const d = line.powershell
|
|
1144
|
+
? checkPowerShellCommand(line.text, inside, boundary, opts.writeRoots, opts.scratchDir, opts.onWrite)
|
|
1145
|
+
: checkBashCommand(line.text, inside, boundary, opts.depth + 1, opts.writeRoots, opts.scratchDir, opts.onWrite);
|
|
1146
|
+
if (!d.ok) return stop(d);
|
|
1147
|
+
}
|
|
1148
|
+
let next = cwd;
|
|
1149
|
+
const input = wrapped.takesInput ? { pipedInto: false, prevCmd: null } : {};
|
|
1150
|
+
for (const command of wrapped.commands ?? []) {
|
|
1151
|
+
const r = checkSimpleCommand([...command, ...redirections], inside, boundary, { ...opts, ...input });
|
|
1152
|
+
if (r.decision && !r.decision.ok) return stop(r.decision);
|
|
1153
|
+
if (wrapped.sameShell) next = r.cwd;
|
|
1154
|
+
}
|
|
1155
|
+
return { decision: null, cwd: next, own: wrapped.own };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Commands the rules decide by name, for telling a command from other text in code. Kept to names that are seldom the
|
|
1160
|
+
* first word of a sentence: `start`, `time` or `command` in front of a string would make a command of prose.
|
|
1161
|
+
*/
|
|
1162
|
+
const DECIDED_BY_NAME = new Set(['git', 'rm', 'mv', 'cp', 'tee', 'touch', 'mkdir', 'rmdir', 'truncate', 'sed', 'dd', 'sh', 'bash', 'zsh', 'cmd', 'powershell', 'pwsh', 'env', 'xargs', 'find', 'gh', 'sudo', 'timeout', 'nohup']);
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* The commands written out in code given to an interpreter on the command line, which runs them as surely as a wrapper
|
|
1166
|
+
* does: a quoted string that begins with the name of a command the rules decide (`os.system('git push')`,
|
|
1167
|
+
* `execSync("rm -rf src")`), and a run of one-word strings that begins with one (`subprocess.run(['git', 'push'])`).
|
|
1168
|
+
* Each is judged as a command line. This reads text and no more: a command the code puts together from pieces, takes
|
|
1169
|
+
* from a variable or runs from a script file is not seen.
|
|
1170
|
+
*/
|
|
1171
|
+
function commandsInCode(code: string): string[] {
|
|
1172
|
+
const strings = [...code.matchAll(/'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"/g)].map((m) => (m[1] ?? m[2] ?? '').replace(/\\(["'])/g, '$1').trim());
|
|
1173
|
+
const named = (text: string): boolean => { const name = commandName(text.split(/\s+/)[0] ?? '').toLowerCase(); return DECIDED_BY_NAME.has(name); };
|
|
1174
|
+
const lines: string[] = [];
|
|
1175
|
+
for (let i = 0; i < strings.length; i += 1) {
|
|
1176
|
+
if (!strings[i] || !named(strings[i]!)) continue;
|
|
1177
|
+
if (/\s/.test(strings[i]!)) { lines.push(strings[i]!); continue; }
|
|
1178
|
+
let end = i + 1;
|
|
1179
|
+
while (end < strings.length && end < i + 16 && strings[end] && !/\s/.test(strings[end]!)) end += 1;
|
|
1180
|
+
lines.push(strings.slice(i, end).map((w) => (/^[\w@%+=:,./-]+$/.test(w) ? w : `'${w.replace(/'/g, `'\\''`)}'`)).join(' '));
|
|
1181
|
+
}
|
|
1182
|
+
return lines;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** Analyse one simple command with the working directory it runs in; returns the next directory (after `cd`). */
|
|
1186
|
+
function checkSimpleCommand(words: Token[], cwd: string, boundary: Boundary, opts: CommandOptions): { decision: CommandDecision | null; cwd: string } {
|
|
1187
|
+
// Leading VAR=value assignments set the command's environment; their path values are checked, then skipped as operands.
|
|
1188
|
+
let start = 0;
|
|
1189
|
+
while (start < words.length && words[start]!.kind === 'word' && /^[A-Za-z_]\w*=/.test(words[start]!.text)) {
|
|
1190
|
+
const w = words[start]!;
|
|
1191
|
+
const eq = /^([A-Za-z_]\w*)=(.*)$/.exec(w.text)!;
|
|
1192
|
+
const name = eq[1]!;
|
|
1193
|
+
const value = eq[2]!;
|
|
1194
|
+
if (w.substitution) return { decision: undeterminable('a command substitution in an environment assignment'), cwd };
|
|
1195
|
+
if (value && (PATH_ENV.has(name) || looksLikePath(value))) {
|
|
1196
|
+
if (w.dynamic) return { decision: undeterminable('a computed path in an environment assignment'), cwd };
|
|
1197
|
+
const d = checkPath(value, cwd, boundary);
|
|
1198
|
+
if (d) return { decision: d, cwd };
|
|
1199
|
+
}
|
|
1200
|
+
start += 1;
|
|
1201
|
+
}
|
|
1202
|
+
let argv = words.slice(start);
|
|
1203
|
+
const trace = traceSwitch(words, start);
|
|
1204
|
+
if (trace) return { decision: trace, cwd };
|
|
1205
|
+
const runner = gitRunnerSetting(words, start);
|
|
1206
|
+
if (runner) return { decision: runner, cwd };
|
|
1207
|
+
if (argv.length === 0) return { decision: null, cwd };
|
|
1208
|
+
// A command that runs another command: that one is judged first, as a command of its own (see `checkWrapped`).
|
|
1209
|
+
const wrapped = checkWrapped(argv, cwd, boundary, opts);
|
|
1210
|
+
if (wrapped && (wrapped.decision || !wrapped.own)) return { decision: wrapped.decision, cwd: wrapped.cwd };
|
|
1211
|
+
if (wrapped?.own) argv = wrapped.own;
|
|
1212
|
+
const name = commandName(argv[0]!.text);
|
|
1213
|
+
// The sed of macOS takes the backup suffix of -i as a word of its own, usually an empty one: `sed -i '' 's/a/b/' file`.
|
|
1214
|
+
// That word is no operand. Counted as one, it made the expression a file sed writes — inside the project, so refused,
|
|
1215
|
+
// whichever file was edited. (Git Bash's sed has no such word; there the command is read as before.)
|
|
1216
|
+
if (name === 'sed' && system.platform !== 'win32') argv = argv.filter((w, i, all) => !(w.text === '' && i > 0 && /^-[A-Za-z]*i$/.test(all[i - 1]!.text)));
|
|
1217
|
+
const interp = INTERPRETERS[name];
|
|
1218
|
+
const credential = credentialCommand(argv);
|
|
1219
|
+
if (credential) return { decision: credential, cwd };
|
|
1220
|
+
if (printsLogin(argv.filter((w) => w.kind === 'word').map((w) => w.text))) return { decision: LOGIN_PRINTER_REFUSAL, cwd };
|
|
1221
|
+
|
|
1222
|
+
// Code piped into an interpreter runs without ever being an argument the check can see.
|
|
1223
|
+
if (opts.pipedInto && interp && !argv.slice(1).some((w) => interp.flags.includes(w.text))) {
|
|
1224
|
+
return { decision: refuse(`${name} (piped input)`, `code piped into ${name} cannot be checked; run the commands directly, or a script the boundary can read.`), cwd };
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Redirection targets are filenames, not command operands. Refuse known project writes before spawning;
|
|
1228
|
+
// the post-command snapshot also catches writes hidden in scripts and tools.
|
|
1229
|
+
const redirectTargets = new Set<Token>();
|
|
1230
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
1231
|
+
if (words[i]!.kind !== 'op' || !REDIRECTS.has(words[i]!.text)) continue;
|
|
1232
|
+
const target = words[i + 1];
|
|
1233
|
+
if (!target || target.kind !== 'word') continue;
|
|
1234
|
+
redirectTargets.add(target);
|
|
1235
|
+
if (words[i]!.text === '<' || words[i]!.text === '0<') {
|
|
1236
|
+
if (target.substitution || target.dynamic) return { decision: undeterminable('a redirection from a computed path'), cwd };
|
|
1237
|
+
const d = checkPath(target.text, cwd, boundary);
|
|
1238
|
+
if (d) return { decision: d, cwd };
|
|
1239
|
+
} else if (!['<<', '<<-', '<<<'].includes(words[i]!.text)) {
|
|
1240
|
+
if (target.dynamic || target.substitution) return { decision: undeterminable('a computed output path'), cwd };
|
|
1241
|
+
opts.onWrite?.(target.text, cwd);
|
|
1242
|
+
if (inWriteRoot(target.text, cwd, opts.writeRoots)) return { decision: projectWrite(target.text), cwd };
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (name === 'eval') return { decision: undeterminable('eval'), cwd };
|
|
1247
|
+
|
|
1248
|
+
// macOS's own tools, by the name the shell finds them under (a script of the project that happens to be called
|
|
1249
|
+
// `pbpaste` is given by its path, and is read as a script).
|
|
1250
|
+
if (system.platform === 'darwin' && (!/[\\/]/.test(argv[0]!.text) || /^\/usr\/s?bin\//.test(argv[0]!.text))) {
|
|
1251
|
+
const why = MAC_REACHES_OUTSIDE[name];
|
|
1252
|
+
if (why) return { decision: refuse(name, why), cwd };
|
|
1253
|
+
if (name === 'mdfind' && !argv.slice(1).some((w) => w.text === '-onlyin')) {
|
|
1254
|
+
return { decision: refuse('mdfind', 'mdfind searches the whole machine; keep it to a directory of the project with -onlyin, or use grep or find in the project.'), cwd };
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// These commands have literal destination operands. Unknown or indirect writes are caught by the snapshot.
|
|
1259
|
+
if (new Set(['tee', 'cp', 'mv', 'rm', 'touch', 'mkdir', 'rmdir', 'truncate']).has(name)
|
|
1260
|
+
|| (name === 'sed' && argv.slice(1).some((w) => /^-.*i/.test(w.text)))) {
|
|
1261
|
+
const operands = argv.slice(1).filter((w) => w.kind === 'word' && !w.text.startsWith('-') && !redirectTargets.has(w));
|
|
1262
|
+
const destinations = name === 'cp' || name === 'mv' ? operands.slice(-1) : name === 'sed' ? operands.slice(1) : operands;
|
|
1263
|
+
for (const target of destinations) {
|
|
1264
|
+
if (target.dynamic || target.substitution) return { decision: undeterminable('a computed output path'), cwd };
|
|
1265
|
+
opts.onWrite?.(target.text, cwd);
|
|
1266
|
+
if (inWriteRoot(target.text, cwd, opts.writeRoots)) return { decision: projectWrite(target.text), cwd };
|
|
1267
|
+
}
|
|
1268
|
+
// mv also removes its sources, so they are written too (the refusal above stays about the destination).
|
|
1269
|
+
if (name === 'mv') for (const source of operands.slice(0, -1)) if (!source.dynamic && !source.substitution) opts.onWrite?.(source.text, cwd);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (name === 'cd' || name === 'pushd') {
|
|
1273
|
+
const arg = argv.slice(1).find((w) => !w.text.startsWith('-') || w.text === '-');
|
|
1274
|
+
if (!arg) return { decision: name === 'cd' ? refuse('cd', 'cd with no directory goes to the home directory, outside the project.') : null, cwd: name === 'cd' ? cwd : CWD_UNTRACKABLE };
|
|
1275
|
+
if (arg.text === '-') return { decision: null, cwd: CWD_UNTRACKABLE }; // cd - : the previous directory, which we cannot follow
|
|
1276
|
+
if (arg.substitution || arg.dynamic) return { decision: undeterminable('a computed directory in cd'), cwd };
|
|
1277
|
+
const d = checkPath(arg.text, cwd, boundary); // a cd target is a read location (later commands read it)
|
|
1278
|
+
if (d) return { decision: d, cwd };
|
|
1279
|
+
return { decision: null, cwd: toAbsolute(arg.text, cwd) };
|
|
1280
|
+
}
|
|
1281
|
+
if (name === 'popd') return { decision: null, cwd: CWD_UNTRACKABLE };
|
|
1282
|
+
if (name === 'tr') return { decision: null, cwd }; // both operands are character sets, never paths
|
|
1283
|
+
|
|
1284
|
+
if (name === 'source' || name === '.') {
|
|
1285
|
+
const arg = argv.slice(1).find((w) => !w.text.startsWith('-'));
|
|
1286
|
+
if (arg) { const d = scanScriptFile(arg, cwd, boundary, opts.depth, opts.writeRoots, opts.scratchDir, opts.onWrite); if (d) return { decision: d, cwd }; }
|
|
1287
|
+
return { decision: null, cwd };
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Follow-symlink flags on a recursive command can leave the tree through a link inside it.
|
|
1291
|
+
const followRe = FOLLOW_LINK[name];
|
|
1292
|
+
const followBsd = system.platform === 'win32' ? undefined : FOLLOW_LINK_BSD[name];
|
|
1293
|
+
if ((followRe && argv.slice(1).some((w) => followRe.test(w.text))) || (followBsd && argv.slice(1).some((w) => followBsd.test(w.text)))) {
|
|
1294
|
+
return { decision: refuse(`${name} (follow symlinks)`, `${name} here follows symbolic links, which can leave the project; use the non-following form (for example grep -r, find without -L).`), cwd };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// A list of paths read from a file (tar --files-from, xargs --arg-file, or `cat file | xargs`).
|
|
1298
|
+
if (name === 'tar' || name === 'xargs') {
|
|
1299
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
1300
|
+
const t = argv[i]!.text;
|
|
1301
|
+
const listFlag = (name === 'tar' && (t === '-T' || t === '--files-from')) || (name === 'xargs' && (t === '-a' || t === '--arg-file'));
|
|
1302
|
+
const eq = /^(?:--files-from|--arg-file)=(.+)$/.exec(t);
|
|
1303
|
+
if (listFlag && argv[i + 1]) { const d = scanListFile(argv[i + 1]!, cwd, boundary); if (d) return { decision: d, cwd }; i += 1; }
|
|
1304
|
+
else if (eq) { const d = scanListFile({ kind: 'word', text: eq[1]!, dynamic: false, substitution: false }, cwd, boundary); if (d) return { decision: d, cwd }; }
|
|
1305
|
+
}
|
|
1306
|
+
if (name === 'xargs' && opts.pipedInto && opts.prevCmd) {
|
|
1307
|
+
const src = stdinFileOf(opts.prevCmd.words);
|
|
1308
|
+
if (src) { const d = scanListFile(src, opts.prevCmd.cwd, boundary); if (d) return { decision: d, cwd }; }
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// git subcommands that move the working tree elsewhere, and git config's scope.
|
|
1313
|
+
let gitCwd = cwd;
|
|
1314
|
+
if (name === 'git') {
|
|
1315
|
+
// Only what is known to read runs (see `gitRule`).
|
|
1316
|
+
const rule = gitRule(argv.filter((w) => !redirectTargets.has(w) && !(w.kind === 'op' && REDIRECTS.has(w.text))));
|
|
1317
|
+
if (rule.refusal) return { decision: rule.refusal, cwd };
|
|
1318
|
+
const subcommand = rule.subcommand;
|
|
1319
|
+
const subcommandIndex = rule.index < 0 ? argv.length : argv.findIndex((w) => w.kind === 'word' && w.text === subcommand && !w.text.startsWith('-'));
|
|
1320
|
+
if (subcommand === 'config') {
|
|
1321
|
+
const d = checkGitConfig(argv, cwd, boundary);
|
|
1322
|
+
if (d && !d.ok) return { decision: d, cwd };
|
|
1323
|
+
}
|
|
1324
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
1325
|
+
const w = argv[i]!;
|
|
1326
|
+
const attached = /^-C(.+)$/.exec(w.text);
|
|
1327
|
+
const eq = /^(--git-dir|--work-tree)=(.+)$/.exec(w.text);
|
|
1328
|
+
if (w.text === '-C' || w.text === '--git-dir' || w.text === '--work-tree') {
|
|
1329
|
+
const val = argv[i + 1];
|
|
1330
|
+
if (val) {
|
|
1331
|
+
if (val.substitution || val.dynamic) return { decision: undeterminable('a computed path in git'), cwd };
|
|
1332
|
+
const d = checkPath(val.text, gitCwd, boundary);
|
|
1333
|
+
if (d) return { decision: d, cwd };
|
|
1334
|
+
if (w.text === '-C') gitCwd = toAbsolute(val.text, gitCwd);
|
|
1335
|
+
i += 1;
|
|
1336
|
+
}
|
|
1337
|
+
} else if (attached) {
|
|
1338
|
+
const d = checkPath(attached[1]!, gitCwd, boundary);
|
|
1339
|
+
if (d) return { decision: d, cwd };
|
|
1340
|
+
gitCwd = toAbsolute(attached[1]!, gitCwd);
|
|
1341
|
+
} else if (eq) {
|
|
1342
|
+
if (w.substitution || w.dynamic) return { decision: undeterminable('a computed path in git'), cwd };
|
|
1343
|
+
const d = checkPath(eq[2]!, gitCwd, boundary);
|
|
1344
|
+
if (d) return { decision: d, cwd };
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
// The files git's output options write (`git log --output=F`, `git format-patch -o DIR`, `git archive -o F`).
|
|
1348
|
+
for (let i = subcommandIndex + 1; i < argv.length; i += 1) {
|
|
1349
|
+
const w = argv[i]!;
|
|
1350
|
+
if (w.kind !== 'word' || redirectTargets.has(w)) continue;
|
|
1351
|
+
const next = argv[i + 1];
|
|
1352
|
+
const shortOutput = w.text === '-o' && (subcommand === 'format-patch' || subcommand === 'archive')
|
|
1353
|
+
&& next?.kind === 'word' && !next.dynamic && !next.substitution ? next.text : null;
|
|
1354
|
+
const target = outputTarget('git', w, next) ?? shortOutput;
|
|
1355
|
+
const names = /^--output(?:-directory)?$/.test(w.text) || (w.text === '-o' && (subcommand === 'format-patch' || subcommand === 'archive'));
|
|
1356
|
+
if ((/^--output(?:-directory)?=/.test(w.text) && (w.dynamic || w.substitution)) || (names && (next?.dynamic || next?.substitution))) return { decision: undeterminable('a computed output path'), cwd };
|
|
1357
|
+
if (target) { opts.onWrite?.(target, gitCwd); if (inWriteRoot(target, gitCwd, opts.writeRoots)) return { decision: projectWrite(target), cwd }; }
|
|
1358
|
+
}
|
|
1359
|
+
for (const file of rule.reads) { const d = checkPath(file.text, gitCwd, boundary); if (d) return { decision: d, cwd }; }
|
|
1360
|
+
for (const file of rule.writes) { opts.onWrite?.(file.text, gitCwd); if (inWriteRoot(file.text, gitCwd, opts.writeRoots)) return { decision: projectWrite(file.text), cwd }; }
|
|
1361
|
+
if (subcommand === 'format-patch' && !argv.some((w) => /^(?:--stdout|-o|--output-directory(?:=.*)?)$/.test(w.text))) return { decision: projectWrite('git format-patch'), cwd };
|
|
1362
|
+
return { decision: null, cwd };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Interpreters running inline code, or a script file.
|
|
1366
|
+
if (interp) {
|
|
1367
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
1368
|
+
if (interp.flags.includes(argv[i]!.text)) {
|
|
1369
|
+
const code = argv[i + 1];
|
|
1370
|
+
if (!code) continue;
|
|
1371
|
+
if (code.substitution) return { decision: undeterminable('a command substitution in inline code'), cwd };
|
|
1372
|
+
if (interp.bash) { const r = checkBashCommand(code.text, cwd, boundary, opts.depth + 1, opts.writeRoots, opts.scratchDir, opts.onWrite); if (!r.ok) return { decision: r, cwd }; }
|
|
1373
|
+
else {
|
|
1374
|
+
if (CREDENTIAL_IN_CODE.test(code.text)) return { decision: CREDENTIAL_REFUSAL, cwd };
|
|
1375
|
+
if (printsLogin(['', ...wordsOfCode(code.text)])) return { decision: LOGIN_PRINTER_REFUSAL, cwd };
|
|
1376
|
+
for (const line of commandsInCode(code.text)) { const r = checkBashCommand(line, cwd, boundary, opts.depth + 1, opts.writeRoots, opts.scratchDir, opts.onWrite); if (!r.ok) return { decision: r, cwd }; }
|
|
1377
|
+
if (homeDerivedInCode(code.text)) return { decision: refuse('inline code', 'the inline code builds a path from the home directory, which is outside the project.'), cwd };
|
|
1378
|
+
const d = checkInlineCode(code.text, cwd, boundary); if (d) return { decision: d, cwd };
|
|
1379
|
+
}
|
|
1380
|
+
return { decision: null, cwd };
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
// No inline-code flag: an interpreter with a script-file argument runs that file.
|
|
1384
|
+
const script = argv.slice(1).find((w) => !w.text.startsWith('-'));
|
|
1385
|
+
if (script) { const d = scanScriptFile(script, cwd, boundary, opts.depth, opts.writeRoots, opts.scratchDir, opts.onWrite); if (d) return { decision: d, cwd }; return { decision: null, cwd }; }
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// A script or binary given by path (not a PATH-resolved name) is itself read, and its contents scanned, when it runs.
|
|
1389
|
+
if (looksLikePath(argv[0]!.text)) {
|
|
1390
|
+
const d = scanScriptFile(argv[0]!, cwd, boundary, opts.depth, opts.writeRoots, opts.scratchDir, opts.onWrite);
|
|
1391
|
+
if (d) return { decision: d, cwd };
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// awk, sed and the grep family: the program or the pattern is not a path; the files they read are.
|
|
1395
|
+
const program = PROGRAM_COMMANDS[name];
|
|
1396
|
+
if (program) return { decision: checkProgramCommand(program, argv, redirectTargets, cwd, boundary), cwd };
|
|
1397
|
+
|
|
1398
|
+
// General arguments: flag values that take a read path, and every operand (resolved against the running directory).
|
|
1399
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
1400
|
+
const w = argv[i]!;
|
|
1401
|
+
if (w.kind !== 'word' || redirectTargets.has(w)) continue;
|
|
1402
|
+
// An output option's value is a file the command writes; it is still checked below like any other operand.
|
|
1403
|
+
const output = outputTarget(name, w, argv[i + 1]);
|
|
1404
|
+
if (output) opts.onWrite?.(output, gitCwd);
|
|
1405
|
+
if (PATH_VALUE_FLAGS.has(w.text)) {
|
|
1406
|
+
const val = argv[i + 1];
|
|
1407
|
+
if (val && val.kind === 'word') {
|
|
1408
|
+
if (val.substitution || val.dynamic) return { decision: undeterminable('a computed path'), cwd };
|
|
1409
|
+
const d = checkPath(val.text, gitCwd, boundary);
|
|
1410
|
+
if (d) return { decision: d, cwd };
|
|
1411
|
+
i += 1;
|
|
1412
|
+
}
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
const eqFlag = /^--[\w-]+=(.+)$/.exec(w.text);
|
|
1416
|
+
if (eqFlag) {
|
|
1417
|
+
if (w.substitution) return { decision: undeterminable('a command substitution'), cwd };
|
|
1418
|
+
if (w.dynamic && looksLikePath(eqFlag[1]!)) return { decision: undeterminable('a computed path'), cwd };
|
|
1419
|
+
if (!w.dynamic) { const d = checkPath(eqFlag[1]!, gitCwd, boundary); if (d) return { decision: d, cwd }; }
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
if (w.text.startsWith('-')) continue; // other flags
|
|
1423
|
+
if (w.substitution) return { decision: undeterminable('a command substitution'), cwd };
|
|
1424
|
+
if (w.dynamic && looksLikePath(w.text)) return { decision: undeterminable('a computed path'), cwd };
|
|
1425
|
+
if (w.dynamic) continue; // a dynamic bare word is not a path position
|
|
1426
|
+
const d = checkPath(w.text, gitCwd, boundary);
|
|
1427
|
+
if (d) return { decision: d, cwd };
|
|
1428
|
+
}
|
|
1429
|
+
return { decision: null, cwd };
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/** Extract quoted string literals from inline interpreter code and check those that look like paths. */
|
|
1433
|
+
function checkInlineCode(code: string, cwd: string, boundary: Boundary): CommandDecision | null {
|
|
1434
|
+
for (const m of code.matchAll(/'([^']*)'|"([^"]*)"/g)) {
|
|
1435
|
+
const literal = (m[1] ?? m[2] ?? '');
|
|
1436
|
+
if (looksLikePath(literal)) { const d = checkPath(literal, cwd, boundary); if (d) return d; }
|
|
1437
|
+
}
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/** The file a command reads its stdin from: `cat FILE`, `type FILE`, or a `< FILE` redirection. */
|
|
1442
|
+
function stdinFileOf(words: readonly Token[]): Token | null {
|
|
1443
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
1444
|
+
if ((words[i]!.text === '<' || words[i]!.text === '0<') && words[i + 1]?.kind === 'word') return words[i + 1]!;
|
|
1445
|
+
}
|
|
1446
|
+
const name = commandName(words[0]?.text ?? '');
|
|
1447
|
+
if (name === 'cat' || name === 'type') { const f = words.slice(1).find((w) => w.kind === 'word' && !w.text.startsWith('-')); return f ?? null; }
|
|
1448
|
+
return null;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* awk, sed and the grep family: the program or pattern is not a path — `/^## Heading/{f=1} f` or `/api/` names no file
|
|
1453
|
+
* — so it is not checked as one; the files the command reads are, whether an operand names them or an option does
|
|
1454
|
+
* (`awk -f`, `sed -f`, `grep -f`, attached or not). Options are read as getopt reads them: a cluster of short options,
|
|
1455
|
+
* a value attached or in the next word, `--name=value` or `--name value`, `--` ending the options. A command
|
|
1456
|
+
* substitution anywhere — a pattern included — runs a command that cannot be checked, and is refused.
|
|
1457
|
+
*/
|
|
1458
|
+
function checkProgramCommand(spec: ProgramCommand, argv: readonly Token[], skip: ReadonlySet<Token>, cwd: string, boundary: Boundary): CommandDecision | null {
|
|
1459
|
+
let programGiven = false;
|
|
1460
|
+
const operands: Token[] = [];
|
|
1461
|
+
/** An option's value: a file it reads is checked; the program, a pattern or other text is not a path. */
|
|
1462
|
+
const value = (kind: ValueKind | null, text: string, word: Token): CommandDecision | null => {
|
|
1463
|
+
if (word.substitution) return undeterminable('a command substitution');
|
|
1464
|
+
if (kind === 'program' || kind === 'program-file') programGiven = true;
|
|
1465
|
+
if (kind === 'program' || kind === 'text' || kind === 'count') return null;
|
|
1466
|
+
if (kind === null) { // a long option this list does not know: checked as before
|
|
1467
|
+
if (word.dynamic) return looksLikePath(text) ? undeterminable('a computed path') : null;
|
|
1468
|
+
return checkPath(text, cwd, boundary);
|
|
1469
|
+
}
|
|
1470
|
+
if (word.dynamic) return undeterminable('a computed path');
|
|
1471
|
+
return checkPath(text, cwd, boundary);
|
|
1472
|
+
};
|
|
1473
|
+
const takes = (kind: ValueKind, next: Token | undefined): next is Token =>
|
|
1474
|
+
next !== undefined && next.kind === 'word' && !skip.has(next) && (kind !== 'count' || /^\d+$/.test(next.text));
|
|
1475
|
+
let optionsDone = false;
|
|
1476
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
1477
|
+
const w = argv[i]!;
|
|
1478
|
+
if (w.kind !== 'word' || skip.has(w)) continue;
|
|
1479
|
+
if (optionsDone || w.text === '-' || !w.text.startsWith('-')) { operands.push(w); continue; }
|
|
1480
|
+
if (w.text === '--') { optionsDone = true; continue; }
|
|
1481
|
+
if (spec.noProgram?.includes(w.text.replace(/=.*$/, ''))) programGiven = true;
|
|
1482
|
+
if (w.text.startsWith('--')) {
|
|
1483
|
+
const eq = w.text.indexOf('=');
|
|
1484
|
+
const kind = spec.long[eq < 0 ? w.text : w.text.slice(0, eq)] ?? null;
|
|
1485
|
+
if (eq >= 0) { const d = value(kind, w.text.slice(eq + 1), w); if (d) return d; }
|
|
1486
|
+
else if (kind && takes(kind, argv[i + 1])) { const d = value(kind, argv[i + 1]!.text, argv[i + 1]!); if (d) return d; i += 1; }
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
// A cluster of short options: the first that takes a value takes the rest of the word, or else the next word.
|
|
1490
|
+
for (let j = 1; j < w.text.length; j += 1) {
|
|
1491
|
+
const letter = w.text[j]!;
|
|
1492
|
+
if (spec.attachedOnly?.includes(letter)) break;
|
|
1493
|
+
const kind = spec.short[letter];
|
|
1494
|
+
if (!kind) continue;
|
|
1495
|
+
const rest = w.text.slice(j + 1);
|
|
1496
|
+
if (rest) { const d = value(kind, rest, w); if (d) return d; }
|
|
1497
|
+
else if (takes(kind, argv[i + 1])) { const d = value(kind, argv[i + 1]!.text, argv[i + 1]!); if (d) return d; i += 1; }
|
|
1498
|
+
break;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
for (const [n, w] of operands.entries()) {
|
|
1502
|
+
if (w.substitution) return undeterminable('a command substitution');
|
|
1503
|
+
if (n === 0 && !programGiven) continue; // the program or the pattern
|
|
1504
|
+
if (w.text === '-') continue; // standard input
|
|
1505
|
+
if (spec.assignments && /^[A-Za-z_]\w*=/.test(w.text)) continue; // awk: var=value sets a variable
|
|
1506
|
+
if (w.dynamic) { if (looksLikePath(w.text)) return undeterminable('a computed path'); continue; }
|
|
1507
|
+
const d = checkPath(w.text, cwd, boundary);
|
|
1508
|
+
if (d) return d;
|
|
1509
|
+
}
|
|
1510
|
+
return null;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** Read a file that lists paths (one per line) and check each against the boundary. */
|
|
1514
|
+
function scanListFile(fileToken: Token, cwd: string, boundary: Boundary): CommandDecision | null {
|
|
1515
|
+
if (fileToken.substitution || fileToken.dynamic) return undeterminable('a computed list file');
|
|
1516
|
+
const bad = checkPath(fileToken.text, cwd, boundary);
|
|
1517
|
+
if (bad) return bad;
|
|
1518
|
+
const abs = toAbsolute(fileToken.text, cwd);
|
|
1519
|
+
let content = '';
|
|
1520
|
+
try {
|
|
1521
|
+
if (statSync(abs).size > MAX_SCAN_BYTES) return undeterminable('a path list too large to inspect');
|
|
1522
|
+
content = readFileSync(abs, 'utf8');
|
|
1523
|
+
} catch { return undeterminable('a path list that cannot be inspected before execution'); }
|
|
1524
|
+
for (const line of content.split(/\r?\n/)) {
|
|
1525
|
+
const p = line.trim();
|
|
1526
|
+
if (!p) continue;
|
|
1527
|
+
const d = checkPath(p, cwd, boundary);
|
|
1528
|
+
if (d) return d;
|
|
1529
|
+
}
|
|
1530
|
+
return null;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Check a whole command line (bash family). `cwd` is where the command starts; a `cd` on the line moves the directory
|
|
1535
|
+
* for the commands after it, a subshell `( … )` keeps its `cd` to itself, and a here-document body is scanned for paths.
|
|
1536
|
+
*/
|
|
1537
|
+
export function checkBashCommand(command: string, cwd: string, boundary: Boundary, depth = 0, writeRoots: readonly string[] = [], scratchDir?: string, onWrite?: WriteSink): CommandDecision {
|
|
1538
|
+
const { stripped, bodies } = extractHeredocs(command);
|
|
1539
|
+
for (const body of bodies) { const d = scanTextForPaths(body, cwd, boundary); if (d) return d; }
|
|
1540
|
+
const nodes = toNodes(knownScratch(tokenize(stripped), scratchDir));
|
|
1541
|
+
const canonicalWriteRoots = writeRoots.map((root) => canonicalKey(root, root));
|
|
1542
|
+
const cwdStack = [cwd];
|
|
1543
|
+
const top = () => cwdStack[cwdStack.length - 1]!;
|
|
1544
|
+
let pipedInto = false;
|
|
1545
|
+
let prevCmd: { words: Token[]; cwd: string } | null = null;
|
|
1546
|
+
for (const node of nodes) {
|
|
1547
|
+
if (node.kind === 'op') {
|
|
1548
|
+
if (node.op === '(') cwdStack.push(top());
|
|
1549
|
+
else if (node.op === ')') { if (cwdStack.length > 1) cwdStack.pop(); prevCmd = null; pipedInto = false; }
|
|
1550
|
+
else if (node.op === '|' || node.op === '|&') { pipedInto = true; continue; }
|
|
1551
|
+
else { prevCmd = null; pipedInto = false; }
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
const runningCwd = top();
|
|
1555
|
+
if (runningCwd === CWD_UNTRACKABLE) {
|
|
1556
|
+
// The working directory is unknown after `cd -` / `popd`. Only an explicit directory change can recover it.
|
|
1557
|
+
const firstWord = node.words.find((w) => w.kind === 'word');
|
|
1558
|
+
const cmd = firstWord ? commandName(firstWord.text) : '';
|
|
1559
|
+
if (cmd !== 'cd' && cmd !== 'pushd') return undeterminable('a directory this guardrail can no longer follow (after cd - or popd)');
|
|
1560
|
+
}
|
|
1561
|
+
if (node.words.some((w) => w.substitution)) return undeterminable('a command substitution');
|
|
1562
|
+
const { decision, cwd: next } = checkSimpleCommand(node.words, runningCwd, boundary, { pipedInto, prevCmd, depth, writeRoots: canonicalWriteRoots, scratchDir, onWrite });
|
|
1563
|
+
if (decision && !decision.ok) return decision;
|
|
1564
|
+
cwdStack[cwdStack.length - 1] = next;
|
|
1565
|
+
prevCmd = { words: node.words, cwd: runningCwd };
|
|
1566
|
+
pipedInto = false;
|
|
1567
|
+
}
|
|
1568
|
+
return OK;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/** A word of a PowerShell or cmd line with its backslashes as the separator the path library of this system reads. */
|
|
1572
|
+
export function inSystemSeparators(text: string, separator: string = sep): string {
|
|
1573
|
+
return separator === '\\' ? text : text.replaceAll('\\', separator);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* PowerShell command boundary. PowerShell's own dynamic forms ($(...), backticks, subexpressions, Invoke-Expression)
|
|
1578
|
+
* are refused outright; the attached `-Param:value` form is split so its value is checked; otherwise path-looking
|
|
1579
|
+
* tokens are checked. pi runs bash by default, so this is the conservative fallback for the powershell tool.
|
|
1580
|
+
*
|
|
1581
|
+
* The line is read with its backslashes as they are, and the braces of a script block as words of their own, so the
|
|
1582
|
+
* command inside a block is judged like any other (`checkWrapped`). `&` calls what follows it: a command there that a
|
|
1583
|
+
* variable names cannot be known.
|
|
1584
|
+
*
|
|
1585
|
+
* To PowerShell, and to cmd, a backslash separates the names of a path on every system. The path library that places
|
|
1586
|
+
* a word does not agree everywhere: off Windows `..\outside\secret.txt` is to it one odd file name in the
|
|
1587
|
+
* directory at hand, so the path was taken for a file of the project. The words of the line are therefore given the
|
|
1588
|
+
* system's own separator here, once, before any of them is read as a path (`inSystemSeparators`).
|
|
1589
|
+
*/
|
|
1590
|
+
export function checkPowerShellCommand(command: string, cwd: string, boundary: Boundary, writeRoots: readonly string[] = [], scratchDir?: string, onWrite?: WriteSink): CommandDecision {
|
|
1591
|
+
if (/\$\(|`|\biex\b|\bInvoke-Expression\b/i.test(command)) return undeterminable('a PowerShell subexpression or Invoke-Expression');
|
|
1592
|
+
const tokens = knownScratch(tokenize(command, true), scratchDir).flatMap((word): Token[] => {
|
|
1593
|
+
const t = word.kind === 'word' ? { ...word, text: inSystemSeparators(word.text) } : word;
|
|
1594
|
+
const braces = t.kind === 'word' ? /^(\{*)([^]*?)(\}*)$/.exec(t.text) : null;
|
|
1595
|
+
if (!braces || (!braces[1] && !braces[3])) return [t];
|
|
1596
|
+
return [...[...braces[1]!].map(() => wordToken('{')), ...(braces[2] ? [{ ...t, text: braces[2] }] : []), ...[...braces[3]!].map(() => wordToken('}'))];
|
|
1597
|
+
});
|
|
1598
|
+
for (const t of tokens) {
|
|
1599
|
+
if (t.kind !== 'word') continue;
|
|
1600
|
+
const attached = /^-[A-Za-z]+:(.+)$/.exec(t.text); // -Path:'C:\…' attaches the value to the parameter
|
|
1601
|
+
if (attached) {
|
|
1602
|
+
if (t.substitution || t.dynamic) return undeterminable('a computed PowerShell parameter');
|
|
1603
|
+
const d = checkPath(attached[1]!, cwd, boundary);
|
|
1604
|
+
if (d) return d;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
const nodes = toNodes(tokens);
|
|
1608
|
+
let pipedInto = false;
|
|
1609
|
+
let prevCmd: { words: Token[]; cwd: string } | null = null;
|
|
1610
|
+
let called = false;
|
|
1611
|
+
for (const node of nodes) {
|
|
1612
|
+
if (node.kind === 'op') { pipedInto = node.op === '|' || node.op === '|&'; called = node.op === '&'; if (!pipedInto) prevCmd = null; continue; }
|
|
1613
|
+
if (called && node.words[0]?.dynamic) return undeterminable('a command named by a variable');
|
|
1614
|
+
const { decision } = checkSimpleCommand(node.words, cwd, boundary, { pipedInto, prevCmd, depth: 0, writeRoots: writeRoots.map((root) => canonicalKey(root, root)), scratchDir, onWrite, powershell: true });
|
|
1615
|
+
if (decision && !decision.ok) return decision;
|
|
1616
|
+
prevCmd = { words: node.words, cwd };
|
|
1617
|
+
pipedInto = false;
|
|
1618
|
+
called = false;
|
|
1619
|
+
}
|
|
1620
|
+
return OK;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
export function checkShellCommand(shell: 'bash' | 'powershell', command: string, cwd: string, boundary: Boundary, writeRoots: readonly string[] = [], scratchDir?: string, onWrite?: WriteSink): CommandDecision {
|
|
1624
|
+
return shell === 'powershell' ? checkPowerShellCommand(command, cwd, boundary, writeRoots, scratchDir, onWrite) : checkBashCommand(command, cwd, boundary, 0, writeRoots, scratchDir, onWrite);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/** Locations actually resolved by the preflight, plus the shell's starting directory. The write
|
|
1628
|
+
* journal uses these same decisions instead of guessing paths with a second parser. */
|
|
1629
|
+
export interface ShellCommandPlan {
|
|
1630
|
+
readonly decision: CommandDecision;
|
|
1631
|
+
readonly locations: readonly string[];
|
|
1632
|
+
readonly explicitPaths: readonly string[];
|
|
1633
|
+
/** The places the command says it writes (see the header), as canonical keys: on a live project the write guard
|
|
1634
|
+
* undoes only changes at or under these (BQ). */
|
|
1635
|
+
readonly writePaths: readonly string[];
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
export function planShellCommand(
|
|
1639
|
+
shell: 'bash' | 'powershell', command: string, cwd: string, boundary: Boundary,
|
|
1640
|
+
writeRoots: readonly string[], scratchDir: string, excludedRoots: readonly string[] = [],
|
|
1641
|
+
): ShellCommandPlan {
|
|
1642
|
+
const start = canonicalKey(cwd, cwd);
|
|
1643
|
+
const excluded = excludedRoots.map((path) => canonicalKey(path, path));
|
|
1644
|
+
const within = (path: string, root: string) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
1645
|
+
const explicit = new Set<string>();
|
|
1646
|
+
const observed: Boundary = {
|
|
1647
|
+
decide(path, from) {
|
|
1648
|
+
const key = canonicalKey(path, from);
|
|
1649
|
+
explicit.add(key);
|
|
1650
|
+
if (excluded.some((root) => within(key, root))) {
|
|
1651
|
+
return { ok: false, reason: `Refused: ${path} is an Excluded project location and is outside Keeper's shell boundary.` };
|
|
1652
|
+
}
|
|
1653
|
+
if (key.split(sep).includes('.git')) {
|
|
1654
|
+
return { ok: false, reason: `Refused: ${path} is Git metadata; shell cannot change it.` };
|
|
1655
|
+
}
|
|
1656
|
+
return boundary.decide(path, from);
|
|
1657
|
+
},
|
|
1658
|
+
describe: () => boundary.describe(),
|
|
1659
|
+
};
|
|
1660
|
+
const writes = new Set<string>();
|
|
1661
|
+
const decision = excluded.some((root) => within(start, root))
|
|
1662
|
+
? refuse(cwd, 'the shell starts in an Excluded project location.')
|
|
1663
|
+
: checkShellCommand(shell, command, cwd, observed, writeRoots, scratchDir, (path, from) => { writes.add(canonicalKey(path, from)); });
|
|
1664
|
+
return { decision, locations: [...new Set([start, ...explicit])], explicitPaths: [...explicit], writePaths: [...writes] };
|
|
1665
|
+
}
|