instabox 0.5.4 → 0.6.1
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/bin/instabox.mjs +45 -3
- package/lib/api.mjs +8 -1
- package/lib/telemetry.mjs +97 -0
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +31 -20
- package/skills/instabox-basic-setup/SKILL.md +57 -57
- package/skills/instabox-basic-setup/scripts/content.mjs +10 -5
- package/skills/instabox-basic-setup/scripts/install-files.py +82 -0
- package/skills/instabox-basic-setup/scripts/lib.mjs +61 -39
- package/skills/instabox-basic-setup/scripts/plan.mjs +248 -230
- package/skills/instabox-basic-setup/scripts/push.mjs +318 -151
- package/skills/instabox-basic-setup/scripts/scan.mjs +73 -46
- package/skills/instabox-move-repo/SKILL.md +22 -12
- package/skills/instabox-snapshots/SKILL.md +8 -2
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# One-time config copying, run as the sandbox user. This is not Secret delivery.
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
HOME = '/data/home'
|
|
8
|
+
MARKER = 'INSTABOX-BASIC-SETUP '
|
|
9
|
+
DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def valid_path(path):
|
|
13
|
+
return (isinstance(path, str) and 0 < len(path) <= 255
|
|
14
|
+
and not any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in path)
|
|
15
|
+
and all(p and p != '.' and not p.startswith('..') for p in path.split('/')))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def install_files(files, home=HOME):
|
|
19
|
+
results = []
|
|
20
|
+
for entry in files:
|
|
21
|
+
path = entry.get('path')
|
|
22
|
+
result = {'path': path, 'status': 'failed'}
|
|
23
|
+
results.append(result)
|
|
24
|
+
if not valid_path(path) or not isinstance(entry.get('text'), str):
|
|
25
|
+
continue
|
|
26
|
+
data = entry['text'].encode('utf-8')
|
|
27
|
+
if len(data) > 64 * 1024 or b'\x00' in data:
|
|
28
|
+
continue
|
|
29
|
+
fd = None
|
|
30
|
+
temporary = None
|
|
31
|
+
try:
|
|
32
|
+
fd = os.open(home, DIRECTORY)
|
|
33
|
+
parts = path.split('/')
|
|
34
|
+
for part in parts[:-1]:
|
|
35
|
+
try:
|
|
36
|
+
os.mkdir(part, 0o700, dir_fd=fd)
|
|
37
|
+
except FileExistsError:
|
|
38
|
+
pass
|
|
39
|
+
child = os.open(part, DIRECTORY, dir_fd=fd)
|
|
40
|
+
os.close(fd)
|
|
41
|
+
fd = child
|
|
42
|
+
name = parts[-1]
|
|
43
|
+
try:
|
|
44
|
+
current = os.stat(name, dir_fd=fd, follow_symlinks=False)
|
|
45
|
+
if not stat.S_ISREG(current.st_mode):
|
|
46
|
+
continue
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
pass
|
|
49
|
+
temporary = '.instabox-setup-' + os.urandom(8).hex()
|
|
50
|
+
output = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd)
|
|
51
|
+
with os.fdopen(output, 'wb') as stream:
|
|
52
|
+
stream.write(data)
|
|
53
|
+
stream.flush()
|
|
54
|
+
os.fsync(stream.fileno())
|
|
55
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
56
|
+
os.rename(temporary, name, src_dir_fd=fd, dst_dir_fd=fd)
|
|
57
|
+
temporary = None
|
|
58
|
+
result['status'] = 'written'
|
|
59
|
+
except Exception:
|
|
60
|
+
# The remote shell and filesystem are untrusted. Return only fixed
|
|
61
|
+
# outcomes; exception text and config contents never reach logs.
|
|
62
|
+
pass
|
|
63
|
+
finally:
|
|
64
|
+
if fd is not None:
|
|
65
|
+
if temporary:
|
|
66
|
+
try:
|
|
67
|
+
os.unlink(temporary, dir_fd=fd)
|
|
68
|
+
except OSError:
|
|
69
|
+
pass
|
|
70
|
+
os.close(fd)
|
|
71
|
+
return results
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == '__main__':
|
|
75
|
+
try:
|
|
76
|
+
payload = json.load(sys.stdin)
|
|
77
|
+
result = install_files(payload['files'])
|
|
78
|
+
sys.stdout.write(MARKER + json.dumps({'files': result}) + '\n')
|
|
79
|
+
sys.exit(0 if all(item['status'] == 'written' for item in result) else 1)
|
|
80
|
+
except Exception:
|
|
81
|
+
sys.stdout.write(MARKER + '{"error":"config copy failed"}\n')
|
|
82
|
+
sys.exit(1)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// The one rule every function here keeps: a credential's value is never
|
|
4
4
|
// returned to the agent, stored in a file or printed. Code that looks at a
|
|
5
5
|
// secret returns names, places and sizes only.
|
|
6
|
+
import { isIP } from 'node:net';
|
|
6
7
|
import { spawnSync } from 'node:child_process';
|
|
7
8
|
import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
@@ -82,9 +83,23 @@ export const WELL_KNOWN = new Set([
|
|
|
82
83
|
'DIGITALOCEAN_ACCESS_TOKEN',
|
|
83
84
|
]);
|
|
84
85
|
|
|
85
|
-
//
|
|
86
|
-
const RESERVED = new Set([
|
|
87
|
-
|
|
86
|
+
// Reserved Secret variable names (internal/secrets/validate.go).
|
|
87
|
+
const RESERVED = new Set([
|
|
88
|
+
'HOME',
|
|
89
|
+
'PATH',
|
|
90
|
+
'USER',
|
|
91
|
+
'LOGNAME',
|
|
92
|
+
'SHELL',
|
|
93
|
+
'TERM',
|
|
94
|
+
'XDG_RUNTIME_DIR',
|
|
95
|
+
'DBUS_SESSION_BUS_ADDRESS',
|
|
96
|
+
'SSL_CERT_FILE',
|
|
97
|
+
'SSL_CERT_DIR',
|
|
98
|
+
'REQUESTS_CA_BUNDLE',
|
|
99
|
+
'CURL_CA_BUNDLE',
|
|
100
|
+
'NODE_EXTRA_CA_CERTS',
|
|
101
|
+
]);
|
|
102
|
+
export function secretNameOK(name) {
|
|
88
103
|
return (
|
|
89
104
|
/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
|
|
90
105
|
name.length <= 128 &&
|
|
@@ -100,12 +115,13 @@ export const isSecretName = (name) => SECRET_NAME.test(name);
|
|
|
100
115
|
export const MACOS_ONLY =
|
|
101
116
|
/\/opt\/homebrew|\/usr\/local\/Cellar|\/usr\/local\/Homebrew|\/Applications\/|\/Library\/|\bosxkeychain\b|\bUseKeychain\b|\bpbcopy\b|\bpbpaste\b|\bosascript\b|\breattach-to-user-namespace\b|\bterminal-notifier\b|\bafplay\b|\/System\/|\.orbstack\/|1password.*agent\.sock|\bsecurity (?:find|add)-[a-z-]*password\b/i;
|
|
102
117
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
export const
|
|
106
|
-
export const
|
|
107
|
-
export const VAULT_MAX_ITEMS = 100;
|
|
118
|
+
// Org Secret limits; the server may override these in .limits.vault (legacy wire name).
|
|
119
|
+
export const SECRET_MAX_VALUE = 64 * 1024;
|
|
120
|
+
export const SECRET_MAX_TOTAL = 256 * 1024;
|
|
121
|
+
export const SECRET_MAX_ITEMS = 100;
|
|
108
122
|
|
|
123
|
+
export const FILE_MAX_VALUE = 64 * 1024;
|
|
124
|
+
export const FILE_MAX_TOTAL = 512 * 1024;
|
|
109
125
|
export const BOX_HOME = '/data/home';
|
|
110
126
|
|
|
111
127
|
// Runs a command without a shell; never throws. stdout is returned to the caller only:
|
|
@@ -184,43 +200,49 @@ export function human(bytes) {
|
|
|
184
200
|
return `${v >= 10 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
|
|
185
201
|
}
|
|
186
202
|
|
|
187
|
-
//
|
|
188
|
-
export function
|
|
189
|
-
return
|
|
190
|
-
.replace(/^\./, '')
|
|
191
|
-
.replaceAll('/', '-')
|
|
192
|
-
.replace(/[^A-Za-z0-9._-]/g, '-')
|
|
193
|
-
.replace(/^-+|-+$/g, '')
|
|
194
|
-
.slice(0, 128);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// A Vault file path must be relative, clean, without .. components (docs/vault.md).
|
|
198
|
-
export function vaultPathOK(p) {
|
|
199
|
-
if (!p || p.startsWith('/') || p.length > 255 || /[\0\\\n]/.test(p)) return false;
|
|
203
|
+
// Paths are relative to the guest home. Config copying never follows symlinks.
|
|
204
|
+
export function configPathOK(p) {
|
|
205
|
+
if (typeof p !== 'string' || !p || p.startsWith('/') || p.length > 255 || /[\x00-\x1f\x7f\\]/.test(p)) return false;
|
|
200
206
|
return p.split('/').every((c) => c !== '' && c !== '.' && !c.startsWith('..'));
|
|
201
207
|
}
|
|
202
208
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
209
|
+
export function deliveryPolicy(delivery, hosts = []) {
|
|
210
|
+
if (!['env', 'proxy'].includes(delivery) || !Array.isArray(hosts)) throw new Error('delivery must be env or proxy');
|
|
211
|
+
if (delivery === 'env' && hosts.length) throw new Error('env delivery cannot enforce hosts; use -');
|
|
212
|
+
if (delivery === 'proxy' && (!hosts.length || hosts.length > 32))
|
|
213
|
+
throw new Error('proxy delivery requires 1-32 exact hosts');
|
|
214
|
+
const normalized = hosts.map((name) => {
|
|
215
|
+
if (typeof name !== 'string') throw new Error('hosts must be exact public DNS names');
|
|
216
|
+
const h = name.toLowerCase().replace(/\.$/, '');
|
|
217
|
+
if (
|
|
218
|
+
h.length > 253 ||
|
|
219
|
+
!h.includes('.') ||
|
|
220
|
+
isIP(h) ||
|
|
221
|
+
/(?:^|\.)(?:local|localhost)$/.test(h) ||
|
|
222
|
+
h.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
|
|
223
|
+
) {
|
|
224
|
+
throw new Error('hosts must be exact public DNS names without a scheme, port, path, IP address or wildcard');
|
|
225
|
+
}
|
|
226
|
+
return h;
|
|
227
|
+
});
|
|
228
|
+
return { delivery, hosts: [...new Set(normalized)].sort() };
|
|
215
229
|
}
|
|
216
230
|
|
|
217
|
-
export function
|
|
218
|
-
|
|
219
|
-
return e.length ? e.map(([k, v]) => `${k}=${v}`).join(',') : '*';
|
|
231
|
+
export function validID(s) {
|
|
232
|
+
return typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(s);
|
|
220
233
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
234
|
+
export function secretIdentity(s) {
|
|
235
|
+
return {
|
|
236
|
+
id: s.id,
|
|
237
|
+
name: s.name,
|
|
238
|
+
orgId: s.orgId,
|
|
239
|
+
version: s.version,
|
|
240
|
+
delivery: s.delivery,
|
|
241
|
+
hosts: [...(s.hosts || [])].sort(),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
export function sameSecret(a, b) {
|
|
245
|
+
return !!a && !!b && JSON.stringify(secretIdentity(a)) === JSON.stringify(secretIdentity(b));
|
|
224
246
|
}
|
|
225
247
|
|
|
226
248
|
// True when the module is the script node was started with (not imported by a test).
|