@proagentstore/cli 0.4.155 → 0.4.156
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.
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { confineWorkDir } from "./workdir-guard.js";
|
|
1
2
|
import { actDigest } from "./input-ledger.js";
|
|
2
3
|
import { parkRestore, parkSnapshot } from "./park-snapshot.js";
|
|
3
4
|
import { runObjective } from "./run-objective.js";
|
|
@@ -84,21 +85,26 @@ export class CodingRuntime {
|
|
|
84
85
|
* workDir (authoritative even for managed clone dirs); fall back to an explicit path
|
|
85
86
|
* (the cloud passes the D1 `repo.workdir` when the session map is empty after a runner
|
|
86
87
|
* restart). Expands a leading `~` the same way start() does.
|
|
88
|
+
*
|
|
89
|
+
* An explicit path is CONFINED (#1165): never the home folder or above it, never inside a
|
|
90
|
+
* credential directory — so `{ workDir: "~", path: ".ssh/id_ed25519" }` is refused rather
|
|
91
|
+
* than a confined read of a private key. See `workdir-guard.ts`.
|
|
87
92
|
*/
|
|
88
93
|
resolveWorkDir(input) {
|
|
89
94
|
if (input.sessionId) {
|
|
90
95
|
const s = this.sessions.get(input.sessionId);
|
|
96
|
+
// The session's own folder answers to the same rule: a session started in `~` must not reopen it.
|
|
91
97
|
if (s)
|
|
92
|
-
return s.config.workDir;
|
|
98
|
+
return confineWorkDir(s.config.workDir, { allow: [this.reposBaseDir] });
|
|
93
99
|
}
|
|
94
100
|
if (input.workDir)
|
|
95
|
-
return
|
|
101
|
+
return confineWorkDir(input.workDir, { allow: [this.reposBaseDir] });
|
|
96
102
|
throw new InspectError("no session or workDir to inspect");
|
|
97
103
|
}
|
|
98
104
|
/** Where a parked run's checkout is (#1077): its declared folder, else the managed clone dir `start()` uses. */
|
|
99
105
|
parkWorkDir(input) {
|
|
100
106
|
if (input.workDir)
|
|
101
|
-
return
|
|
107
|
+
return confineWorkDir(input.workDir, { allow: [this.reposBaseDir] });
|
|
102
108
|
if (input.repoId)
|
|
103
109
|
return join(this.reposBaseDir, sanitizeSessionName(input.repoId));
|
|
104
110
|
throw new InspectError("no workDir or repoId for the parked checkout");
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync,
|
|
1
|
+
import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { RunnerInputError } from "../errors.js";
|
|
5
|
+
import { isWithin, realPathOf } from "./workdir-guard.js";
|
|
5
6
|
/**
|
|
6
7
|
* The runner's half of the machine-to-machine secret handoff (#918): read a secret file so the
|
|
7
8
|
* cloud can encrypt it, and write one back from the cloud's decrypted copy.
|
|
@@ -18,23 +19,44 @@ import { RunnerInputError } from "../errors.js";
|
|
|
18
19
|
*/
|
|
19
20
|
/** Big enough for any `.env`, key file or credentials JSON; small enough that this is not a file-transfer tool. */
|
|
20
21
|
export const SECRET_MAX_BYTES = 64 * 1024;
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The ONE folder the secret handoff reads from and writes to (#1165).
|
|
24
|
+
*
|
|
25
|
+
* It used to take any path the owner's account could reach: `/secure/read` would hand over
|
|
26
|
+
* `~/.ssh/id_ed25519`, and `/secure/write` with `overwrite: true` could replace `~/.ssh/authorized_keys`.
|
|
27
|
+
* Now a relative path is relative to this folder, and an absolute or `~/…` path must already be inside
|
|
28
|
+
* it, resolved through symlinks, so a link planted in it cannot point back out. Moving a delivered
|
|
29
|
+
* file to where it is used is the operator's step, in the open.
|
|
30
|
+
*/
|
|
31
|
+
export function secureFilesDir(home = homedir()) {
|
|
32
|
+
return join(home, ".config", "proagentstore", "secure-files");
|
|
33
|
+
}
|
|
34
|
+
/** `~` expanded; relative to {@link secureFilesDir}; refused unless it stays inside that folder. */
|
|
35
|
+
function resolvePath(raw, home = homedir()) {
|
|
23
36
|
const p = String(raw ?? "").trim();
|
|
24
37
|
if (!p)
|
|
25
38
|
throw new RunnerInputError("A `path` is required.");
|
|
26
|
-
const
|
|
27
|
-
|
|
39
|
+
const root = secureFilesDir(home);
|
|
40
|
+
const expanded = p.replace(/^~(?=$|\/)/, home);
|
|
41
|
+
const path = isAbsolute(expanded) ? resolve(expanded) : resolve(root, expanded);
|
|
42
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
43
|
+
if (path === root || !isWithin(realPathOf(path, home), realPathOf(root, home))) {
|
|
44
|
+
throw new RunnerInputError(`${p} is outside ${root}. The secret handoff reads and writes only inside that folder — name a file there, e.g. \`app.env\`.`);
|
|
45
|
+
}
|
|
46
|
+
return path;
|
|
28
47
|
}
|
|
29
|
-
export function readSecretFile(input) {
|
|
30
|
-
const path = resolvePath(input.path);
|
|
48
|
+
export function readSecretFile(input, home = homedir()) {
|
|
49
|
+
const path = resolvePath(input.path, home);
|
|
31
50
|
let st;
|
|
32
51
|
try {
|
|
33
|
-
st =
|
|
52
|
+
st = lstatSync(path);
|
|
34
53
|
}
|
|
35
54
|
catch {
|
|
36
55
|
throw new RunnerInputError(`No file at ${path}.`);
|
|
37
56
|
}
|
|
57
|
+
// Not followed (#1165): a symlink in the folder would read whatever it points at.
|
|
58
|
+
if (st.isSymbolicLink())
|
|
59
|
+
throw new RunnerInputError(`${path} is a symbolic link; the secret handoff reads regular files only.`);
|
|
38
60
|
if (!st.isFile())
|
|
39
61
|
throw new RunnerInputError(`${path} is not a regular file.`);
|
|
40
62
|
if (st.size === 0)
|
|
@@ -63,8 +85,8 @@ function parseMode(raw) {
|
|
|
63
85
|
throw new RunnerInputError(`mode ${s} is group/world-writable or executable; a secret file must not be.`);
|
|
64
86
|
return mode;
|
|
65
87
|
}
|
|
66
|
-
export function writeSecretFile(input) {
|
|
67
|
-
const path = resolvePath(input.path);
|
|
88
|
+
export function writeSecretFile(input, home = homedir()) {
|
|
89
|
+
const path = resolvePath(input.path, home);
|
|
68
90
|
if (typeof input.value !== "string" || !input.value)
|
|
69
91
|
throw new RunnerInputError("Nothing to write.");
|
|
70
92
|
const mode = parseMode(input.mode);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a caller-supplied `workDir` may be (#1165).
|
|
3
|
+
*
|
|
4
|
+
* The read-only inspection routes (`read-file`, `tree`, `search`, `git`) confine every PATH to the
|
|
5
|
+
* `workDir` — but took the `workDir` itself from the request. `{ workDir: "~", path: ".ssh/id_ed25519" }`
|
|
6
|
+
* was a perfectly confined read of a private key. The cloud sends the owner's registered repo folder
|
|
7
|
+
* (`coding_repos.workdir`), and registration accepts any local path, `~` included; this runner has no
|
|
8
|
+
* copy of that registry to check against. So it refuses what can never be a repository it should
|
|
9
|
+
* read inside, whoever asked:
|
|
10
|
+
*
|
|
11
|
+
* • the filesystem root, the home folder, or any folder ABOVE the home folder — a root that holds
|
|
12
|
+
* everything the owner has;
|
|
13
|
+
* • a folder inside a credential directory ({@link CREDENTIAL_DIRS}) — `~/.ssh`, `~/.gnupg`, the
|
|
14
|
+
* cloud CLIs' config, and ProAgentStore's own (`~/.config/proagentstore`, which holds the account
|
|
15
|
+
* session), except the managed clones the runner itself makes there.
|
|
16
|
+
*
|
|
17
|
+
* Resolved through symlinks first (the nearest existing ancestor, for a folder a clone will create), so
|
|
18
|
+
* `~/dev/innocent -> ~/.ssh` is the credential directory it points at.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
|
+
import { InspectError } from "./inspect.js";
|
|
24
|
+
/** Folders under the home folder whose contents are credentials, never a repository to read. */
|
|
25
|
+
export const CREDENTIAL_DIRS = [
|
|
26
|
+
".ssh",
|
|
27
|
+
".gnupg",
|
|
28
|
+
".aws",
|
|
29
|
+
".azure",
|
|
30
|
+
".kube",
|
|
31
|
+
".docker",
|
|
32
|
+
".password-store",
|
|
33
|
+
".config/gcloud",
|
|
34
|
+
".config/gh",
|
|
35
|
+
".config/proagentstore",
|
|
36
|
+
"Library/Keychains",
|
|
37
|
+
];
|
|
38
|
+
/** `~` expanded; resolved through symlinks as far as the path exists. */
|
|
39
|
+
export function realPathOf(raw, home = homedir()) {
|
|
40
|
+
const expanded = raw.trim().replace(/^~(?=$|[\\/])/, home);
|
|
41
|
+
let p = resolve(isAbsolute(expanded) ? expanded : join(home, expanded));
|
|
42
|
+
const tail = [];
|
|
43
|
+
while (!existsSync(p)) {
|
|
44
|
+
const up = dirname(p);
|
|
45
|
+
if (up === p)
|
|
46
|
+
break;
|
|
47
|
+
tail.unshift(basename(p));
|
|
48
|
+
p = up;
|
|
49
|
+
}
|
|
50
|
+
let real = p;
|
|
51
|
+
try {
|
|
52
|
+
real = realpathSync(p);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Unreadable: judge the path as written.
|
|
56
|
+
}
|
|
57
|
+
return tail.length ? join(real, ...tail) : real;
|
|
58
|
+
}
|
|
59
|
+
/** Is `child` the same as `parent` or inside it? */
|
|
60
|
+
export function isWithin(child, parent) {
|
|
61
|
+
const rel = relative(parent, child);
|
|
62
|
+
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The `workDir` a request may name, or an {@link InspectError} saying why not. `allow` lists folders the
|
|
66
|
+
* runner itself manages inside a credential directory — the managed clone root — which stay usable.
|
|
67
|
+
*/
|
|
68
|
+
export function confineWorkDir(raw, opts = {}) {
|
|
69
|
+
const home = realPathOf(opts.home ?? homedir(), opts.home ?? homedir());
|
|
70
|
+
const path = realPathOf(raw, opts.home ?? homedir());
|
|
71
|
+
if (isWithin(home, path)) {
|
|
72
|
+
throw new InspectError(`"${raw}" is ${path === home ? "the home folder" : "a folder that contains the home folder"}, not a repository — register the repository's own folder.`);
|
|
73
|
+
}
|
|
74
|
+
if ((opts.allow ?? []).some((a) => isWithin(path, realPathOf(a, home))))
|
|
75
|
+
return path;
|
|
76
|
+
const credential = CREDENTIAL_DIRS.find((d) => isWithin(path, realPathOf(join(home, d), home)));
|
|
77
|
+
if (credential)
|
|
78
|
+
throw new InspectError(`"${raw}" is inside ~/${credential}, which holds credentials — it cannot be read as a repository.`);
|
|
79
|
+
return path;
|
|
80
|
+
}
|