surf-cli 2.8.0 → 2.10.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/README.md +146 -8
- package/native/abort.cjs +65 -0
- package/native/activity-journal.cjs +55 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +2 -2
- package/native/chatgpt-client.cjs +49 -31
- package/native/cli.cjs +352 -482
- package/native/client-transport.cjs +168 -0
- package/native/do-executor.cjs +68 -510
- package/native/do-parser.cjs +8 -249
- package/native/doctor.cjs +55 -5
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +156 -71
- package/native/grok-client.cjs +98 -89
- package/native/host-helpers.cjs +43 -26
- package/native/host-sessions.cjs +287 -0
- package/native/host.cjs +998 -620
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -65
- package/native/network-export.cjs +116 -0
- package/native/network-store.cjs +38 -58
- package/native/perplexity-client.cjs +46 -17
- package/native/playbook-authoring.cjs +44 -0
- package/native/playbook-cli.cjs +157 -0
- package/native/playbook-client.cjs +259 -0
- package/native/playbook-receipts.cjs +109 -0
- package/native/playbook-records.cjs +208 -0
- package/native/playbook-runtime.cjs +177 -0
- package/native/playbooks.cjs +235 -0
- package/native/private-state.cjs +156 -0
- package/native/redaction.cjs +104 -0
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +1 -1
- package/native/workflow-definition.cjs +368 -0
- package/native/workflow-runtime.cjs +225 -0
- package/package.json +9 -6
- package/playbooks/page/ops/read.json +22 -0
- package/playbooks/page/playbook.json +7 -0
- package/scripts/install-native-host.cjs +36 -5
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- package/skills/surf/SKILL.md +72 -5
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { atomicWriteJson } = require("./private-state.cjs");
|
|
5
|
+
const { assertNoEmbeddedSecrets, assertUrlHasNoEmbeddedSecrets } = require("./redaction.cjs");
|
|
6
|
+
const { commandMetadata, normalizeStep } = require("./workflow-definition.cjs");
|
|
7
|
+
|
|
8
|
+
const BUILTIN_DIR = path.join(__dirname, "..", "playbooks");
|
|
9
|
+
const PINNED_BUILTINS = new Set(["chatgpt", "aistudio", "x"]);
|
|
10
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
11
|
+
|
|
12
|
+
function playbookDirs({ cwd = process.cwd(), home = os.homedir() } = {}) {
|
|
13
|
+
return [
|
|
14
|
+
{ scope: "project", path: path.join(cwd, ".surf", "playbooks") },
|
|
15
|
+
{ scope: "user", path: path.join(home, ".surf", "playbooks") },
|
|
16
|
+
{ scope: "built-in", path: BUILTIN_DIR },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertId(value, label) {
|
|
21
|
+
if (typeof value !== "string" || !ID_PATTERN.test(value)) throw new Error(`${label} must use lowercase letters, numbers, dot, underscore, or hyphen`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateArgSpecs(args) {
|
|
26
|
+
if (args === undefined) return {};
|
|
27
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error("playbook op args must be an object");
|
|
28
|
+
for (const [name, spec] of Object.entries(args)) {
|
|
29
|
+
assertId(name, "argument name");
|
|
30
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw new Error(`argument ${name} must be an object`);
|
|
31
|
+
}
|
|
32
|
+
return args;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateOrigins(origins) {
|
|
36
|
+
if (origins === undefined) return [];
|
|
37
|
+
if (!Array.isArray(origins)) throw new Error("playbook origins must be an array");
|
|
38
|
+
return origins.map((origin) => {
|
|
39
|
+
if (typeof origin !== "string") throw new Error("playbook origins must be strings");
|
|
40
|
+
let parsed;
|
|
41
|
+
try { parsed = new URL(origin); } catch { throw new Error(`invalid playbook origin: ${origin}`); }
|
|
42
|
+
if (!["http:", "https:"].includes(parsed.protocol) || parsed.origin !== origin) {
|
|
43
|
+
throw new Error(`playbook origin must be an exact HTTP(S) origin: ${origin}`);
|
|
44
|
+
}
|
|
45
|
+
return origin;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validateServerIdempotency(value, opId) {
|
|
50
|
+
if (value === undefined) return undefined;
|
|
51
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
52
|
+
throw new Error(`write op ${opId} serverIdempotency must declare a header, query, or body field`);
|
|
53
|
+
}
|
|
54
|
+
const result = {};
|
|
55
|
+
for (const key of ["header", "query", "body"]) {
|
|
56
|
+
if (value[key] === undefined) continue;
|
|
57
|
+
if (typeof value[key] !== "string" || !value[key] || /[\r\n]/.test(value[key])) {
|
|
58
|
+
throw new Error(`write op ${opId} serverIdempotency.${key} must be a string`);
|
|
59
|
+
}
|
|
60
|
+
result[key] = value[key];
|
|
61
|
+
}
|
|
62
|
+
if (Object.keys(result).length === 0) {
|
|
63
|
+
throw new Error(`write op ${opId} serverIdempotency must declare a header, query, or body field`);
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateWriteWorkflowSteps(steps, opId) {
|
|
69
|
+
if (steps.length !== 1 || steps[0].repeat !== undefined || steps[0].each !== undefined) {
|
|
70
|
+
throw new Error(`write op ${opId} workflow strategy must contain exactly one commit step`);
|
|
71
|
+
}
|
|
72
|
+
const metadata = commandMetadata(steps[0].cmd);
|
|
73
|
+
if (metadata.effect !== "page-write") {
|
|
74
|
+
throw new Error(`write op ${opId} workflow commit step must be page-write`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateStrategy(strategy, effect, opId) {
|
|
79
|
+
if (!strategy || typeof strategy !== "object" || Array.isArray(strategy)) throw new Error("playbook strategy must be an object");
|
|
80
|
+
if (strategy.using === "workflow") {
|
|
81
|
+
if (!Array.isArray(strategy.steps) || strategy.steps.length === 0) throw new Error("workflow strategy requires steps");
|
|
82
|
+
const steps = strategy.steps.map(normalizeStep);
|
|
83
|
+
if (effect === "write") validateWriteWorkflowSteps(steps, opId);
|
|
84
|
+
return { ...strategy, steps };
|
|
85
|
+
}
|
|
86
|
+
if (strategy.using === "network") {
|
|
87
|
+
const request = strategy.request;
|
|
88
|
+
if (!request || typeof request !== "object" || typeof request.url !== "string") throw new Error("network strategy requires request.url");
|
|
89
|
+
const method = String(request.method || "GET").toUpperCase();
|
|
90
|
+
if (effect === "read" && !["GET", "HEAD", "OPTIONS", "POST"].includes(method)) throw new Error("read ops cannot use a mutating network method");
|
|
91
|
+
assertUrlHasNoEmbeddedSecrets(request.url);
|
|
92
|
+
assertNoEmbeddedSecrets(request.headers || {});
|
|
93
|
+
assertNoEmbeddedSecrets(request.query || {});
|
|
94
|
+
assertNoEmbeddedSecrets(request.body);
|
|
95
|
+
return { ...strategy, request: { ...request, method } };
|
|
96
|
+
}
|
|
97
|
+
if (strategy.using === "native") {
|
|
98
|
+
if (typeof strategy.handler !== "string" || !strategy.handler) throw new Error("native strategy requires handler");
|
|
99
|
+
return strategy;
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`unsupported playbook strategy: ${strategy.using}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateOp(value, manifest) {
|
|
105
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("playbook op must be an object");
|
|
106
|
+
const id = assertId(value.id, "op id");
|
|
107
|
+
const effect = value.effect || "read";
|
|
108
|
+
if (effect !== "read" && effect !== "write") throw new Error(`op ${id} effect must be read or write`);
|
|
109
|
+
if (!Array.isArray(value.run) || value.run.length === 0) throw new Error(`op ${id} requires at least one strategy`);
|
|
110
|
+
let safety = value.safety;
|
|
111
|
+
if (effect === "write") {
|
|
112
|
+
if (!value.safety || !["transactional", "repeatable-window"].includes(value.safety.duplicate)) throw new Error(`write op ${id} requires duplicate safety`);
|
|
113
|
+
if (!Array.isArray(value.safety.key) || value.safety.key.length === 0) throw new Error(`write op ${id} requires a semantic safety key`);
|
|
114
|
+
safety = { ...value.safety, authorization: value.safety.authorization || "explicit" };
|
|
115
|
+
if (value.safety.serverIdempotency !== undefined) {
|
|
116
|
+
safety.serverIdempotency = validateServerIdempotency(value.safety.serverIdempotency, id);
|
|
117
|
+
}
|
|
118
|
+
if (!new Set(["explicit", "implicit"]).has(safety.authorization)) throw new Error(`write op ${id} has invalid authorization`);
|
|
119
|
+
if (safety.authorization === "implicit" && safety.duplicate !== "repeatable-window") {
|
|
120
|
+
throw new Error(`implicit write op ${id} must use repeatable-window duplicate safety`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const auth = value.auth === undefined ? {} : value.auth;
|
|
124
|
+
if (!auth || typeof auth !== "object" || Array.isArray(auth)) throw new Error(`op ${id} auth must be an object`);
|
|
125
|
+
const origins = validateOrigins(auth.origins || value.origins || manifest.origins);
|
|
126
|
+
return {
|
|
127
|
+
...value,
|
|
128
|
+
id,
|
|
129
|
+
args: validateArgSpecs(value.args),
|
|
130
|
+
effect,
|
|
131
|
+
...(safety ? { safety } : {}),
|
|
132
|
+
auth: { ...auth, origins },
|
|
133
|
+
run: value.run.map((strategy) => validateStrategy(strategy, effect, id)),
|
|
134
|
+
origins,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function loadPlaybookDirectory(directory, scope) {
|
|
139
|
+
const manifestPath = path.join(directory, "playbook.json");
|
|
140
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
141
|
+
const id = assertId(manifest.id, "playbook id");
|
|
142
|
+
if (typeof manifest.version !== "string" || !manifest.version) throw new Error(`playbook ${id} requires version`);
|
|
143
|
+
const origins = validateOrigins(manifest.origins);
|
|
144
|
+
const opsDir = path.join(directory, "ops");
|
|
145
|
+
const ops = new Map();
|
|
146
|
+
if (!fs.existsSync(opsDir)) throw new Error(`playbook ${id} has no ops directory`);
|
|
147
|
+
for (const file of fs.readdirSync(opsDir).filter((name) => name.endsWith(".json")).sort()) {
|
|
148
|
+
const op = validateOp(JSON.parse(fs.readFileSync(path.join(opsDir, file), "utf8")), { ...manifest, origins });
|
|
149
|
+
if (ops.has(op.id)) throw new Error(`duplicate op ${op.id} in playbook ${id}`);
|
|
150
|
+
ops.set(op.id, op);
|
|
151
|
+
}
|
|
152
|
+
if (ops.size === 0) throw new Error(`playbook ${id} has no ops`);
|
|
153
|
+
return { ...manifest, id, origins, directory, ops, provenance: { scope, path: directory, version: manifest.version } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolvePlaybook(id, options = {}) {
|
|
157
|
+
assertId(id, "playbook id");
|
|
158
|
+
let dirs = playbookDirs(options);
|
|
159
|
+
if (options.pinBuiltIn && PINNED_BUILTINS.has(id)) dirs = [...dirs.filter((entry) => entry.scope === "built-in"), ...dirs.filter((entry) => entry.scope !== "built-in")];
|
|
160
|
+
for (const entry of dirs) {
|
|
161
|
+
const directory = path.join(entry.path, id);
|
|
162
|
+
if (fs.existsSync(path.join(directory, "playbook.json"))) return loadPlaybookDirectory(directory, entry.scope);
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`Playbook not found: ${id}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function listPlaybooks(options = {}) {
|
|
168
|
+
const found = new Map();
|
|
169
|
+
for (const entry of playbookDirs(options)) {
|
|
170
|
+
if (!fs.existsSync(entry.path)) continue;
|
|
171
|
+
for (const name of fs.readdirSync(entry.path).sort()) {
|
|
172
|
+
if (found.has(name) || !fs.existsSync(path.join(entry.path, name, "playbook.json"))) continue;
|
|
173
|
+
try {
|
|
174
|
+
const playbook = loadPlaybookDirectory(path.join(entry.path, name), entry.scope);
|
|
175
|
+
found.set(name, { id: playbook.id, name: playbook.name || playbook.id, description: playbook.description || "", version: playbook.version, scope: entry.scope, ops: playbook.ops.size });
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return [...found.values()];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolveOp(playbookId, opId, options = {}) {
|
|
183
|
+
const playbook = resolvePlaybook(playbookId, options);
|
|
184
|
+
const op = playbook.ops.get(opId);
|
|
185
|
+
if (!op) throw new Error(`Op not found: ${playbookId} ${opId}`);
|
|
186
|
+
return { playbook, op };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function savePlaybook({ manifest, op, scope = "user", cwd = process.cwd(), home = os.homedir() }) {
|
|
190
|
+
if (scope !== "user" && scope !== "project") throw new Error("playbook save scope must be user or project");
|
|
191
|
+
const validatedManifest = { ...manifest, id: assertId(manifest.id, "playbook id"), version: manifest.version || "1.0.0" };
|
|
192
|
+
const validatedOp = validateOp(op, validatedManifest);
|
|
193
|
+
const base = playbookDirs({ cwd, home }).find((entry) => entry.scope === scope).path;
|
|
194
|
+
const directory = path.join(base, validatedManifest.id);
|
|
195
|
+
fs.mkdirSync(path.join(directory, "ops"), { recursive: true });
|
|
196
|
+
atomicWriteJson(path.join(directory, "playbook.json"), validatedManifest);
|
|
197
|
+
atomicWriteJson(path.join(directory, "ops", `${validatedOp.id}.json`), validatedOp);
|
|
198
|
+
return { directory, playbook: validatedManifest.id, op: validatedOp.id, scope };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function writePlaybookDirectory(directory, playbook) {
|
|
202
|
+
if (fs.existsSync(directory)) throw new Error(`playbook output already exists: ${directory}`);
|
|
203
|
+
fs.mkdirSync(path.join(directory, "ops"), { recursive: true });
|
|
204
|
+
const { directory: _directory, provenance: _provenance, ops, ...manifest } = playbook;
|
|
205
|
+
atomicWriteJson(path.join(directory, "playbook.json"), manifest);
|
|
206
|
+
for (const op of ops.values()) atomicWriteJson(path.join(directory, "ops", `${op.id}.json`), op);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function exportPlaybookDirectory(playbookId, { out, cwd = process.cwd(), home = os.homedir() } = {}) {
|
|
210
|
+
if (typeof out !== "string" || !out) throw new Error("playbook export requires --out <directory>");
|
|
211
|
+
const playbook = resolvePlaybook(playbookId, { cwd, home });
|
|
212
|
+
const directory = path.resolve(out);
|
|
213
|
+
writePlaybookDirectory(directory, playbook);
|
|
214
|
+
return { playbook: playbook.id, directory };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function importPlaybookDirectory(source, { scope = "user", cwd = process.cwd(), home = os.homedir() } = {}) {
|
|
218
|
+
if (scope !== "user" && scope !== "project") throw new Error("playbook import scope must be user or project");
|
|
219
|
+
if (typeof source !== "string" || !source) throw new Error("playbook import requires a directory");
|
|
220
|
+
const playbook = loadPlaybookDirectory(path.resolve(source), "import");
|
|
221
|
+
const base = playbookDirs({ cwd, home }).find((entry) => entry.scope === scope).path;
|
|
222
|
+
const directory = path.join(base, playbook.id);
|
|
223
|
+
writePlaybookDirectory(directory, playbook);
|
|
224
|
+
return { playbook: playbook.id, directory, scope };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
exportPlaybookDirectory,
|
|
229
|
+
importPlaybookDirectory,
|
|
230
|
+
listPlaybooks,
|
|
231
|
+
resolveOp,
|
|
232
|
+
resolvePlaybook,
|
|
233
|
+
savePlaybook,
|
|
234
|
+
validateOp,
|
|
235
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const PRIVATE_DIR_MODE = 0o700;
|
|
7
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
8
|
+
|
|
9
|
+
function getPrivateStateRoot(env = process.env) {
|
|
10
|
+
return path.resolve(env.SURF_STATE_DIR || path.join(os.homedir(), ".surf", "state"));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertNotSymlink(targetPath, allowMissing = true) {
|
|
14
|
+
try {
|
|
15
|
+
const stat = fs.lstatSync(targetPath);
|
|
16
|
+
if (stat.isSymbolicLink()) throw new Error(`refusing symbolic link: ${targetPath}`);
|
|
17
|
+
return stat;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (allowMissing && error?.code === "ENOENT") return null;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertWithin(root, targetPath) {
|
|
25
|
+
const resolvedRoot = path.resolve(root);
|
|
26
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
27
|
+
if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
28
|
+
throw new Error(`private state path escapes root: ${targetPath}`);
|
|
29
|
+
}
|
|
30
|
+
return resolvedTarget;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensurePrivateDir(dirPath, root = getPrivateStateRoot()) {
|
|
34
|
+
const resolvedRoot = path.resolve(root);
|
|
35
|
+
const resolvedDir = assertWithin(resolvedRoot, dirPath);
|
|
36
|
+
const rootStat = assertNotSymlink(resolvedRoot, true);
|
|
37
|
+
if (!rootStat) fs.mkdirSync(resolvedRoot, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
38
|
+
const checkedRoot = assertNotSymlink(resolvedRoot, false);
|
|
39
|
+
if (!checkedRoot.isDirectory()) throw new Error(`private state path is not a directory: ${resolvedRoot}`);
|
|
40
|
+
fs.chmodSync(resolvedRoot, PRIVATE_DIR_MODE);
|
|
41
|
+
const relative = path.relative(resolvedRoot, resolvedDir);
|
|
42
|
+
let current = resolvedRoot;
|
|
43
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
44
|
+
current = path.join(current, segment);
|
|
45
|
+
const stat = assertNotSymlink(current, true);
|
|
46
|
+
if (!stat) fs.mkdirSync(current, { mode: PRIVATE_DIR_MODE });
|
|
47
|
+
const checked = assertNotSymlink(current, false);
|
|
48
|
+
if (!checked.isDirectory()) throw new Error(`private state path is not a directory: ${current}`);
|
|
49
|
+
fs.chmodSync(current, PRIVATE_DIR_MODE);
|
|
50
|
+
}
|
|
51
|
+
return resolvedDir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function privateStatePath(...segments) {
|
|
55
|
+
const root = getPrivateStateRoot();
|
|
56
|
+
return assertWithin(root, path.join(root, ...segments));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function prepareOutputDirectory(filePath, options) {
|
|
60
|
+
const dir = path.dirname(filePath);
|
|
61
|
+
if (options.root) return ensurePrivateDir(dir, options.root);
|
|
62
|
+
const stat = assertNotSymlink(dir, false);
|
|
63
|
+
if (!stat.isDirectory()) throw new Error(`output parent is not a directory: ${dir}`);
|
|
64
|
+
return dir;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function atomicWriteFile(filePath, content, options = {}) {
|
|
68
|
+
const resolved = path.resolve(filePath);
|
|
69
|
+
if (options.root) assertWithin(options.root, resolved);
|
|
70
|
+
const dir = prepareOutputDirectory(resolved, options);
|
|
71
|
+
assertNotSymlink(resolved, true);
|
|
72
|
+
const temporaryPath = path.join(dir, `.${path.basename(resolved)}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`);
|
|
73
|
+
let fd;
|
|
74
|
+
try {
|
|
75
|
+
fd = fs.openSync(temporaryPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, PRIVATE_FILE_MODE);
|
|
76
|
+
fs.writeFileSync(fd, content, options.encoding || undefined);
|
|
77
|
+
fs.fsyncSync(fd);
|
|
78
|
+
fs.closeSync(fd);
|
|
79
|
+
fd = undefined;
|
|
80
|
+
assertNotSymlink(resolved, true);
|
|
81
|
+
fs.renameSync(temporaryPath, resolved);
|
|
82
|
+
fs.chmodSync(resolved, PRIVATE_FILE_MODE);
|
|
83
|
+
} finally {
|
|
84
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
85
|
+
try { fs.unlinkSync(temporaryPath); } catch {}
|
|
86
|
+
}
|
|
87
|
+
return resolved;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function atomicWriteJson(filePath, value, options = {}) {
|
|
91
|
+
return atomicWriteFile(filePath, `${JSON.stringify(value, null, 2)}\n`, { ...options, encoding: "utf8" });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function appendPrivateJsonLine(filePath, value, options = {}) {
|
|
95
|
+
const resolved = path.resolve(filePath);
|
|
96
|
+
const root = options.root || getPrivateStateRoot();
|
|
97
|
+
assertWithin(root, resolved);
|
|
98
|
+
ensurePrivateDir(path.dirname(resolved), root);
|
|
99
|
+
assertNotSymlink(resolved, true);
|
|
100
|
+
const fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, PRIVATE_FILE_MODE);
|
|
101
|
+
try {
|
|
102
|
+
fs.writeFileSync(fd, `${JSON.stringify(value)}\n`, "utf8");
|
|
103
|
+
fs.fsyncSync(fd);
|
|
104
|
+
fs.fchmodSync(fd, PRIVATE_FILE_MODE);
|
|
105
|
+
} finally {
|
|
106
|
+
fs.closeSync(fd);
|
|
107
|
+
}
|
|
108
|
+
return resolved;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function writePrivateFileExclusive(filePath, content, options = {}) {
|
|
112
|
+
const resolved = path.resolve(filePath);
|
|
113
|
+
const root = options.root || getPrivateStateRoot();
|
|
114
|
+
assertWithin(root, resolved);
|
|
115
|
+
ensurePrivateDir(path.dirname(resolved), root);
|
|
116
|
+
assertNotSymlink(resolved, true);
|
|
117
|
+
const fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, PRIVATE_FILE_MODE);
|
|
118
|
+
try {
|
|
119
|
+
fs.writeFileSync(fd, content, options.encoding || undefined);
|
|
120
|
+
fs.fsyncSync(fd);
|
|
121
|
+
fs.fchmodSync(fd, PRIVATE_FILE_MODE);
|
|
122
|
+
} finally {
|
|
123
|
+
fs.closeSync(fd);
|
|
124
|
+
}
|
|
125
|
+
return resolved;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readPrivateFile(filePath, options = {}) {
|
|
129
|
+
const resolved = path.resolve(filePath);
|
|
130
|
+
const root = options.root || getPrivateStateRoot();
|
|
131
|
+
assertWithin(root, resolved);
|
|
132
|
+
const stat = assertNotSymlink(resolved, options.allowMissing === true);
|
|
133
|
+
if (!stat) return options.fallback;
|
|
134
|
+
if (!stat.isFile()) throw new Error(`private state path is not a file: ${resolved}`);
|
|
135
|
+
if ((stat.mode & 0o077) !== 0) throw new Error(`private state file permissions are too broad: ${resolved}`);
|
|
136
|
+
return fs.readFileSync(resolved, options.encoding || null);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readPrivateJson(filePath, fallback = null, options = {}) {
|
|
140
|
+
const content = readPrivateFile(filePath, { ...options, allowMissing: true, fallback: null, encoding: "utf8" });
|
|
141
|
+
return content === null ? fallback : JSON.parse(content);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
appendPrivateJsonLine,
|
|
146
|
+
assertNotSymlink,
|
|
147
|
+
assertWithin,
|
|
148
|
+
atomicWriteFile,
|
|
149
|
+
atomicWriteJson,
|
|
150
|
+
ensurePrivateDir,
|
|
151
|
+
getPrivateStateRoot,
|
|
152
|
+
privateStatePath,
|
|
153
|
+
readPrivateFile,
|
|
154
|
+
readPrivateJson,
|
|
155
|
+
writePrivateFileExclusive,
|
|
156
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const SECRET_HEADERS = new Set([
|
|
2
|
+
"authorization",
|
|
3
|
+
"cookie",
|
|
4
|
+
"proxy-authorization",
|
|
5
|
+
"set-cookie",
|
|
6
|
+
"x-api-key",
|
|
7
|
+
"x-csrf-token",
|
|
8
|
+
"x-xsrf-token",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const SECRET_NAME_PATTERN = /(?:^|[-_.])(authorization|cookie|password|passwd|secret|session|token|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|csrf|xsrf)(?:$|[-_.])/i;
|
|
12
|
+
const TEMPLATE_PATTERN = /\{\{[a-zA-Z0-9._-]+\}\}/;
|
|
13
|
+
const CREDENTIAL_LITERAL_PATTERN = /\bbearer\s+[a-z0-9._~-]+|\bsk-[a-z0-9_-]{12,}|\bgh[pousr]_[a-z0-9_]{12,}|\beyj[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+/i;
|
|
14
|
+
|
|
15
|
+
function isTemplate(value) {
|
|
16
|
+
return typeof value === "string" && TEMPLATE_PATTERN.test(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isSensitiveName(name) {
|
|
20
|
+
return SECRET_NAME_PATTERN.test(String(name || ""));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isSecretHeader(name) {
|
|
24
|
+
const lower = String(name || "").toLowerCase();
|
|
25
|
+
return SECRET_HEADERS.has(lower) || isSensitiveName(lower);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function redactSensitiveFields(value) {
|
|
29
|
+
if (Array.isArray(value)) return value.map(redactSensitiveFields);
|
|
30
|
+
if (!value || typeof value !== "object") return value;
|
|
31
|
+
return Object.fromEntries(
|
|
32
|
+
Object.entries(value).map(([key, item]) => [
|
|
33
|
+
key,
|
|
34
|
+
isSensitiveName(key) ? "<redacted>" : redactSensitiveFields(item),
|
|
35
|
+
]),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeHeaders(headers = {}) {
|
|
40
|
+
if (!headers || typeof headers !== "object") return {};
|
|
41
|
+
return Object.fromEntries(Object.entries(headers).filter(([name]) => !isSecretHeader(name)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function redactUrlSecrets(url) {
|
|
45
|
+
if (typeof url !== "string") return url;
|
|
46
|
+
const absolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url);
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = new URL(url, "https://surf.invalid");
|
|
50
|
+
} catch {
|
|
51
|
+
return url;
|
|
52
|
+
}
|
|
53
|
+
for (const [name] of parsed.searchParams) {
|
|
54
|
+
if (isSensitiveName(name)) parsed.searchParams.set(name, "<redacted>");
|
|
55
|
+
}
|
|
56
|
+
return absolute ? parsed.toString() : `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertNoEmbeddedSecrets(value, key = "value") {
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
for (const item of value) assertNoEmbeddedSecrets(item, key);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (value && typeof value === "object") {
|
|
65
|
+
for (const [name, item] of Object.entries(value)) {
|
|
66
|
+
if (isSensitiveName(name)) {
|
|
67
|
+
if (isTemplate(item) || item === undefined || item === null || item === "") continue;
|
|
68
|
+
throw new Error(`playbook requires an auth input instead of a literal ${name}`);
|
|
69
|
+
}
|
|
70
|
+
assertNoEmbeddedSecrets(item, name);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (typeof value === "string" && !isTemplate(value) && CREDENTIAL_LITERAL_PATTERN.test(value)) {
|
|
75
|
+
throw new Error(`playbook contains a credential-like literal in ${key}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertUrlHasNoEmbeddedSecrets(url) {
|
|
80
|
+
if (typeof url !== "string") return;
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = new URL(url, "https://surf.invalid");
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const [name, value] of parsed.searchParams) {
|
|
88
|
+
if (isSensitiveName(name) && !isTemplate(value)) {
|
|
89
|
+
throw new Error(`playbook requires an auth input instead of a literal ${name}`);
|
|
90
|
+
}
|
|
91
|
+
assertNoEmbeddedSecrets(value, name);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
assertNoEmbeddedSecrets,
|
|
97
|
+
assertUrlHasNoEmbeddedSecrets,
|
|
98
|
+
isSecretHeader,
|
|
99
|
+
isSensitiveName,
|
|
100
|
+
isTemplate,
|
|
101
|
+
redactSensitiveFields,
|
|
102
|
+
redactUrlSecrets,
|
|
103
|
+
safeHeaders,
|
|
104
|
+
};
|