@anweat/dsh-browser 0.2.1 → 0.2.3
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 +103 -0
- package/README.en.md +153 -0
- package/README.md +99 -568
- package/assets/skills/dsh-browser/SKILL.md +13 -11
- package/assets/skills/dsh-browser/references/files.md +36 -0
- package/assets/skills/dsh-browser/references/observe.md +2 -2
- package/assets/skills/dsh-browser/references/policy.md +32 -0
- package/cordis.patch.yml +10 -3
- package/lib/actions/errors.js +7 -2
- package/lib/actions/errors.js.map +1 -1
- package/lib/actions/index-view.d.ts +4 -1
- package/lib/actions/index-view.js +16 -3
- package/lib/actions/index-view.js.map +1 -1
- package/lib/actions/inspect.js +15 -0
- package/lib/actions/inspect.js.map +1 -1
- package/lib/actions/observe.js +1 -1
- package/lib/actions/observe.js.map +1 -1
- package/lib/actions/opencli.d.ts +1 -1
- package/lib/actions/opencli.js +4 -4
- package/lib/actions/opencli.js.map +1 -1
- package/lib/actions/policy.d.ts +15 -0
- package/lib/actions/policy.js +131 -0
- package/lib/actions/policy.js.map +1 -0
- package/lib/actions/registry.js +6 -5
- package/lib/actions/registry.js.map +1 -1
- package/lib/actions/runtime.js +2 -2
- package/lib/actions/runtime.js.map +1 -1
- package/lib/actions/target.js +35 -3
- package/lib/actions/target.js.map +1 -1
- package/lib/actions/types.d.ts +1 -1
- package/lib/actions/types.js.map +1 -1
- package/lib/approval-policy.d.ts +10 -1
- package/lib/approval-policy.js +42 -1
- package/lib/approval-policy.js.map +1 -1
- package/lib/auth-profiles.d.ts +4 -1
- package/lib/auth-profiles.js +3 -0
- package/lib/auth-profiles.js.map +1 -1
- package/lib/automation-assets-rpc.js +60 -4
- package/lib/automation-assets-rpc.js.map +1 -1
- package/lib/browser-service.d.ts +165 -4
- package/lib/browser-service.js +505 -55
- package/lib/browser-service.js.map +1 -1
- package/lib/client/SettingsCard.d.ts +10 -8
- package/lib/client/SettingsCard.js +12 -137
- package/lib/client/SettingsCard.js.map +1 -1
- package/lib/client/auth-profiles-form.d.ts +23 -0
- package/lib/client/auth-profiles-form.js +64 -0
- package/lib/client/auth-profiles-form.js.map +1 -0
- package/lib/client/card-frame.d.ts +22 -0
- package/lib/client/card-frame.js +61 -0
- package/lib/client/card-frame.js.map +1 -0
- package/lib/client/field-rows.d.ts +59 -0
- package/lib/client/field-rows.js +63 -0
- package/lib/client/field-rows.js.map +1 -0
- package/lib/client/field-rules.d.ts +17 -0
- package/lib/client/field-rules.js +68 -0
- package/lib/client/field-rules.js.map +1 -0
- package/lib/client/form.d.ts +108 -7
- package/lib/client/form.js +255 -11
- package/lib/client/form.js.map +1 -1
- package/lib/client/group-access.d.ts +14 -0
- package/lib/client/group-access.js +97 -0
- package/lib/client/group-access.js.map +1 -0
- package/lib/client/group-advanced.d.ts +11 -0
- package/lib/client/group-advanced.js +32 -0
- package/lib/client/group-advanced.js.map +1 -0
- package/lib/client/group-assets.d.ts +13 -0
- package/lib/client/group-assets.js +50 -0
- package/lib/client/group-assets.js.map +1 -0
- package/lib/client/group-browser.d.ts +9 -0
- package/lib/client/group-browser.js +27 -0
- package/lib/client/group-browser.js.map +1 -0
- package/lib/client/group-common.d.ts +10 -0
- package/lib/client/group-common.js +21 -0
- package/lib/client/group-common.js.map +1 -0
- package/lib/client/group-context.d.ts +20 -0
- package/lib/client/group-context.js +76 -0
- package/lib/client/group-context.js.map +1 -0
- package/lib/client/group-downloads.d.ts +10 -0
- package/lib/client/group-downloads.js +33 -0
- package/lib/client/group-downloads.js.map +1 -0
- package/lib/client/group-limits.d.ts +9 -0
- package/lib/client/group-limits.js +57 -0
- package/lib/client/group-limits.js.map +1 -0
- package/lib/client/group-prompts.d.ts +13 -0
- package/lib/client/group-prompts.js +136 -0
- package/lib/client/group-prompts.js.map +1 -0
- package/lib/client/index.d.ts +21 -1
- package/lib/client/index.js +10 -4
- package/lib/client/index.js.map +1 -1
- package/lib/client/locales-policy.d.ts +83 -0
- package/lib/client/locales-policy.js +46 -0
- package/lib/client/locales-policy.js.map +1 -0
- package/lib/client/locales-prompts.d.ts +57 -0
- package/lib/client/locales-prompts.js +41 -0
- package/lib/client/locales-prompts.js.map +1 -0
- package/lib/client/locales-ui.d.ts +223 -0
- package/lib/client/locales-ui.js +157 -0
- package/lib/client/locales-ui.js.map +1 -0
- package/lib/client/locales.d.ts +359 -67
- package/lib/client/locales.js +68 -22
- package/lib/client/locales.js.map +1 -1
- package/lib/client/policy-fields.d.ts +31 -0
- package/lib/client/policy-fields.js +66 -0
- package/lib/client/policy-fields.js.map +1 -0
- package/lib/client/policy-status-client.d.ts +91 -0
- package/lib/client/policy-status-client.js +64 -0
- package/lib/client/policy-status-client.js.map +1 -0
- package/lib/client/prompts-form.d.ts +33 -0
- package/lib/client/prompts-form.js +87 -0
- package/lib/client/prompts-form.js.map +1 -1
- package/lib/client/prompts-status-client.d.ts +21 -1
- package/lib/client/prompts-status-client.js +36 -4
- package/lib/client/prompts-status-client.js.map +1 -1
- package/lib/client/properties-form.d.ts +90 -0
- package/lib/client/properties-form.js +187 -0
- package/lib/client/properties-form.js.map +1 -0
- package/lib/client/property-docs.d.ts +18 -0
- package/lib/client/property-docs.js +78 -0
- package/lib/client/property-docs.js.map +1 -0
- package/lib/client/size-units.d.ts +12 -0
- package/lib/client/size-units.js +29 -0
- package/lib/client/size-units.js.map +1 -0
- package/lib/client/styles.d.ts +47 -31
- package/lib/client/styles.js +59 -20
- package/lib/client/styles.js.map +1 -1
- package/lib/client/ui-kit.d.ts +173 -0
- package/lib/client/ui-kit.js +112 -0
- package/lib/client/ui-kit.js.map +1 -0
- package/lib/client.js +5291 -834
- package/lib/client.js.map +1 -1
- package/lib/config-rules.d.ts +40 -0
- package/lib/config-rules.js +103 -0
- package/lib/config-rules.js.map +1 -0
- package/lib/config.d.ts +64 -2
- package/lib/config.js +106 -0
- package/lib/config.js.map +1 -1
- package/lib/context-options.d.ts +85 -0
- package/lib/context-options.js +206 -0
- package/lib/context-options.js.map +1 -0
- package/lib/deps.d.ts +42 -5
- package/lib/deps.js +219 -9
- package/lib/deps.js.map +1 -1
- package/lib/downloads.d.ts +79 -0
- package/lib/downloads.js +189 -0
- package/lib/downloads.js.map +1 -0
- package/lib/index.d.ts +3 -2
- package/lib/index.js +7 -3
- package/lib/index.js.map +1 -1
- package/lib/policy-keys.d.ts +39 -0
- package/lib/policy-keys.js +21 -0
- package/lib/policy-keys.js.map +1 -1
- package/lib/policy-overlay.d.ts +189 -0
- package/lib/policy-overlay.js +649 -0
- package/lib/policy-overlay.js.map +1 -0
- package/lib/prompts-yaml.d.ts +11 -0
- package/lib/prompts-yaml.js +121 -0
- package/lib/prompts-yaml.js.map +1 -0
- package/lib/prompts.d.ts +2 -0
- package/lib/prompts.js +7 -3
- package/lib/prompts.js.map +1 -1
- package/lib/system-browser.d.ts +61 -0
- package/lib/system-browser.js +109 -0
- package/lib/system-browser.js.map +1 -0
- package/lib/tools.js +9 -1
- package/lib/tools.js.map +1 -1
- package/lib/usage-policy.d.ts +3 -1
- package/lib/usage-policy.js +2 -0
- package/lib/usage-policy.js.map +1 -1
- package/package.json +47 -40
- package/scripts/check-docs-links.mjs +115 -0
- package/scripts/dump-prompts.mjs +13 -3
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy overlay: restriction-type settings that an agent may edit, without hand-editing the config.
|
|
3
|
+
*
|
|
4
|
+
* Only two kinds of field can be overlaid: `authProfiles` (allowed domains, persistState, storageStatePath, and
|
|
5
|
+
* creating or removing a profile) and the `usagePolicy` throttle values. The panel's configuration stays the
|
|
6
|
+
* baseline; the overlay is a small managed file next to the plugin's other data (`$DSH_HOME/data/browser/`) that is
|
|
7
|
+
* merged on top of it, with a revision for optimistic locking and a bounded audit trail.
|
|
8
|
+
*
|
|
9
|
+
* Everything an agent may put in the overlay is validated here, strictly, by the same functions that re-validate the
|
|
10
|
+
* file when it is loaded: a file that does not parse, fails its checksum or holds a value the validators refuse is
|
|
11
|
+
* ignored as a whole (the baseline applies) and reported, never half-applied.
|
|
12
|
+
* @module dsh-browser/policy-overlay
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { domainToASCII } from 'node:url';
|
|
18
|
+
import { ActionArgError, ActionUnavailableError } from "./actions/types.js";
|
|
19
|
+
import { AUTH_PROFILE_ID, USAGE_POLICY_BOUNDS, USAGE_POLICY_KEYS } from "./policy-keys.js";
|
|
20
|
+
// -- limits --
|
|
21
|
+
export const MAX_DOMAINS_PER_PROFILE = 50;
|
|
22
|
+
export const MAX_PROFILES = 30;
|
|
23
|
+
export const MAX_AUDIT_ENTRIES = 50;
|
|
24
|
+
const MAX_REASON = 300;
|
|
25
|
+
const MAX_DOMAIN_LENGTH = 253;
|
|
26
|
+
const PROFILE_ID = AUTH_PROFILE_ID;
|
|
27
|
+
const FORBIDDEN_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
28
|
+
const CONTROL_OR_SPACE = /[\u0000-\u0020\u007f-\u009f\u00a0\u2028\u2029\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/;
|
|
29
|
+
// -- domain rules --
|
|
30
|
+
/**
|
|
31
|
+
* Suffixes under which unrelated parties register names. Allowing one as a login scope would send a login state to
|
|
32
|
+
* every tenant of the platform. This is a small built-in list (the platforms people actually hit) plus a structural
|
|
33
|
+
* rule for ccTLD second levels; it is not the full public suffix list.
|
|
34
|
+
*/
|
|
35
|
+
const PUBLIC_SUFFIXES = new Set([
|
|
36
|
+
'github.io', 'gitlab.io', 'pages.dev', 'workers.dev', 'vercel.app', 'netlify.app', 'herokuapp.com', 'blogspot.com',
|
|
37
|
+
'wordpress.com', 'appspot.com', 'azurewebsites.net', 'cloudfront.net', 'amazonaws.com', 's3.amazonaws.com',
|
|
38
|
+
'firebaseapp.com', 'web.app', 'onrender.com', 'fly.dev', 'surge.sh', 'glitch.me', 'repl.co', 'ngrok.io',
|
|
39
|
+
'ngrok-free.app', 'trycloudflare.com', 'myshopify.com', 'weebly.com', 'wixsite.com', 'tumblr.com', 'neocities.org',
|
|
40
|
+
'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'ltd.uk', 'plc.uk', 'sch.uk', 'nhs.uk',
|
|
41
|
+
]);
|
|
42
|
+
const GENERIC_SECOND_LEVEL = new Set(['co', 'com', 'org', 'net', 'gov', 'edu', 'ac', 'ne', 'or', 'go', 'mil', 'gob', 'nom', 'ltd', 'plc', 'sch', 'ind']);
|
|
43
|
+
const PRIVATE_SUFFIXES = ['localhost', 'local', 'internal', 'lan', 'home.arpa', 'intranet', 'corp', 'localdomain', 'private', 'home'];
|
|
44
|
+
/** Whether a (non-wildcard) name is a TLD or a registry/platform suffix, which can never be a login scope. */
|
|
45
|
+
export function isPublicSuffix(name) {
|
|
46
|
+
const labels = name.split('.');
|
|
47
|
+
if (labels.length < 2)
|
|
48
|
+
return true;
|
|
49
|
+
if (PUBLIC_SUFFIXES.has(name))
|
|
50
|
+
return true;
|
|
51
|
+
const top = labels[labels.length - 1];
|
|
52
|
+
const second = labels[labels.length - 2];
|
|
53
|
+
return labels.length === 2 && top.length === 2 && GENERIC_SECOND_LEVEL.has(second);
|
|
54
|
+
}
|
|
55
|
+
function privateSuffix(name) {
|
|
56
|
+
return PRIVATE_SUFFIXES.some(suffix => name === suffix || name.endsWith('.' + suffix));
|
|
57
|
+
}
|
|
58
|
+
/** Plain dotted IPv4 (each octet 0-255, no leading zeros games). */
|
|
59
|
+
function ipv4Octets(name) {
|
|
60
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(name);
|
|
61
|
+
if (!match)
|
|
62
|
+
return undefined;
|
|
63
|
+
const octets = match.slice(1).map(Number);
|
|
64
|
+
if (octets.some((octet, index) => octet > 255 || String(octet) !== match[index + 1]))
|
|
65
|
+
return undefined;
|
|
66
|
+
return octets;
|
|
67
|
+
}
|
|
68
|
+
/** Loopback, RFC 1918. Link-local (169.254/16, which holds the cloud metadata address) and 0.0.0.0 are never private-allowed. */
|
|
69
|
+
function privateIpv4(octets) {
|
|
70
|
+
const [a, b] = octets;
|
|
71
|
+
return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Normalize one allowed-domain entry to its stored form (lower-case punycode, `*.` kept) or throw
|
|
75
|
+
* {@link ActionArgError} saying exactly why it is refused.
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeAllowedDomain(raw, env) {
|
|
78
|
+
const fail = (why) => { throw new ActionArgError(`allowedDomains entry ${JSON.stringify(typeof raw === 'string' ? raw.slice(0, 80) : raw)}: ${why}`, 'Use a bare registrable domain such as example.com, or *.example.com.'); };
|
|
79
|
+
if (typeof raw !== 'string')
|
|
80
|
+
return fail('must be a string');
|
|
81
|
+
if (raw.length === 0 || raw.length > MAX_DOMAIN_LENGTH + 2)
|
|
82
|
+
return fail('must be 1 to 253 characters');
|
|
83
|
+
if (CONTROL_OR_SPACE.test(raw))
|
|
84
|
+
return fail('must not contain whitespace or control characters');
|
|
85
|
+
if (/[:/\\?#@%\[\]]/.test(raw))
|
|
86
|
+
return fail('must be a host name only, without a scheme, port, path, query, credentials or brackets');
|
|
87
|
+
if (raw.endsWith('.'))
|
|
88
|
+
return fail('must not end with a dot');
|
|
89
|
+
let host = raw;
|
|
90
|
+
let wildcard = false;
|
|
91
|
+
if (host.startsWith('*.')) {
|
|
92
|
+
wildcard = true;
|
|
93
|
+
host = host.slice(2);
|
|
94
|
+
}
|
|
95
|
+
if (host.includes('*'))
|
|
96
|
+
return fail('a wildcard is only allowed as the leading label of *.example.com');
|
|
97
|
+
if (host === '')
|
|
98
|
+
return fail('is empty');
|
|
99
|
+
const ascii = domainToASCII(host);
|
|
100
|
+
if (!ascii)
|
|
101
|
+
return fail('is not a valid host name');
|
|
102
|
+
host = ascii.toLowerCase();
|
|
103
|
+
if (host.length > MAX_DOMAIN_LENGTH)
|
|
104
|
+
return fail('is too long');
|
|
105
|
+
const labels = host.split('.');
|
|
106
|
+
if (labels.some(label => label.length === 0 || label.length > 63 || !/^[a-z0-9_-]+$/.test(label) || label.startsWith('-') || label.endsWith('-')))
|
|
107
|
+
return fail('has an invalid label');
|
|
108
|
+
const last = labels[labels.length - 1];
|
|
109
|
+
// WHATWG "ends in a number": 127.1, 0x7f.0.0.1, 2130706433 all reach loopback and are IP literals by another spelling.
|
|
110
|
+
if (/^(0x[0-9a-f]*|\d+)$/i.test(last)) {
|
|
111
|
+
const octets = ipv4Octets(host);
|
|
112
|
+
if (wildcard)
|
|
113
|
+
return fail('a wildcard IP address is never allowed');
|
|
114
|
+
if (!octets)
|
|
115
|
+
return fail('is an IP literal in a non-standard spelling; IP literals are not accepted');
|
|
116
|
+
if (octets[0] === 169 && octets[1] === 254)
|
|
117
|
+
return fail('link-local and cloud metadata addresses are never allowed');
|
|
118
|
+
if (octets.every(octet => octet === 0))
|
|
119
|
+
return fail('0.0.0.0 is never allowed');
|
|
120
|
+
if (!privateIpv4(octets))
|
|
121
|
+
return fail('public IP literals are not accepted; use the host name');
|
|
122
|
+
if (!env.allowPrivateHosts)
|
|
123
|
+
return fail('loopback and private-network addresses need allowPrivateHosts=true in the configuration');
|
|
124
|
+
return host;
|
|
125
|
+
}
|
|
126
|
+
if (privateSuffix(host) || host === 'metadata' || host === 'metadata.google.internal') {
|
|
127
|
+
if (host === 'metadata.google.internal')
|
|
128
|
+
return fail('cloud metadata hosts are never allowed');
|
|
129
|
+
if (wildcard && labels.length === 1 && host !== 'localhost')
|
|
130
|
+
return fail('a wildcard on a bare TLD is never allowed');
|
|
131
|
+
if (!env.allowPrivateHosts)
|
|
132
|
+
return fail('localhost, .local, .internal and other private-network names need allowPrivateHosts=true in the configuration');
|
|
133
|
+
return wildcard ? '*.' + host : host;
|
|
134
|
+
}
|
|
135
|
+
if (labels.length === 1)
|
|
136
|
+
return fail('a bare top-level name (com, org, ...) is not a registrable domain');
|
|
137
|
+
if (isPublicSuffix(host))
|
|
138
|
+
return fail(`"${host}" is a public suffix: it would cover unrelated sites. Use a registrable domain under it`);
|
|
139
|
+
return wildcard ? '*.' + host : host;
|
|
140
|
+
}
|
|
141
|
+
/** The name an entry matches (`*.a.com` and `a.com` scope the same hosts, see hostAllowed). */
|
|
142
|
+
export function domainBase(entry) {
|
|
143
|
+
return entry.startsWith('*.') ? entry.slice(2) : entry;
|
|
144
|
+
}
|
|
145
|
+
export function normalizeAllowedDomains(raw, env) {
|
|
146
|
+
if (!Array.isArray(raw))
|
|
147
|
+
throw new ActionArgError('allowedDomains must be an array of domain names');
|
|
148
|
+
if (raw.length === 0)
|
|
149
|
+
throw new ActionArgError('allowedDomains must hold at least one domain: a profile that matches nothing is removed with runtime.remove_auth_profile');
|
|
150
|
+
if (raw.length > MAX_DOMAINS_PER_PROFILE)
|
|
151
|
+
throw new ActionArgError(`allowedDomains holds ${raw.length} entries; at most ${MAX_DOMAINS_PER_PROFILE} are allowed`);
|
|
152
|
+
const seen = new Map();
|
|
153
|
+
for (const entry of raw) {
|
|
154
|
+
const normalized = normalizeAllowedDomain(entry, env);
|
|
155
|
+
const base = domainBase(normalized);
|
|
156
|
+
if (seen.has(base))
|
|
157
|
+
throw new ActionArgError(`allowedDomains lists ${base} twice (${seen.get(base)} and ${normalized}; they scope the same hosts)`);
|
|
158
|
+
seen.set(base, normalized);
|
|
159
|
+
}
|
|
160
|
+
return [...seen.values()];
|
|
161
|
+
}
|
|
162
|
+
export function checkProfileId(id) {
|
|
163
|
+
if (typeof id !== 'string' || !PROFILE_ID.test(id) || FORBIDDEN_IDS.has(id) || id in Object.prototype) {
|
|
164
|
+
throw new ActionArgError(`profile id ${JSON.stringify(typeof id === 'string' ? id.slice(0, 60) : id)} must start with a letter or digit, use only letters, digits, _ and -, and be at most 40 characters (not a reserved object key)`);
|
|
165
|
+
}
|
|
166
|
+
return id;
|
|
167
|
+
}
|
|
168
|
+
export function checkReason(reason) {
|
|
169
|
+
if (typeof reason !== 'string')
|
|
170
|
+
throw new ActionArgError('reason is required: say why the policy changes');
|
|
171
|
+
const clean = reason.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
|
|
172
|
+
if (clean.length < 3 || clean.length > MAX_REASON)
|
|
173
|
+
throw new ActionArgError(`reason must be 3 to ${MAX_REASON} characters`);
|
|
174
|
+
return clean;
|
|
175
|
+
}
|
|
176
|
+
/** `realpath` of the deepest existing ancestor, with the not-yet-existing rest appended: resolves symlinks without needing the file. */
|
|
177
|
+
function realpathDeep(target) {
|
|
178
|
+
const resolved = path.resolve(target);
|
|
179
|
+
let current = resolved;
|
|
180
|
+
const rest = [];
|
|
181
|
+
for (let guard = 0; guard < 64; guard++) {
|
|
182
|
+
try {
|
|
183
|
+
return path.join(fs.realpathSync(current), ...rest.reverse());
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
const parent = path.dirname(current);
|
|
187
|
+
if (parent === current)
|
|
188
|
+
return resolved;
|
|
189
|
+
rest.push(path.basename(current));
|
|
190
|
+
current = parent;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return resolved;
|
|
194
|
+
}
|
|
195
|
+
function inside(child, parent) {
|
|
196
|
+
const normalize = (value) => process.platform === 'win32' || process.platform === 'darwin' ? value.toLowerCase() : value;
|
|
197
|
+
const c = normalize(child);
|
|
198
|
+
const p = normalize(parent);
|
|
199
|
+
return c === p || c.startsWith(p.endsWith(path.sep) ? p : p + path.sep);
|
|
200
|
+
}
|
|
201
|
+
/** The absolute path of an agent-set auth state file, or an {@link ActionArgError} saying why not. */
|
|
202
|
+
export function checkStorageStatePath(raw, env, takenByOthers = []) {
|
|
203
|
+
const fail = (why) => { throw new ActionArgError(`storageStatePath ${why}`, `The file must be a .json file inside ${env.authDir}.`); };
|
|
204
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.length > 500)
|
|
205
|
+
return fail('must be a path of 1 to 500 characters');
|
|
206
|
+
if (/[\u0000-\u001f\u007f]/.test(raw))
|
|
207
|
+
return fail('must not contain control characters');
|
|
208
|
+
if (!path.isAbsolute(raw))
|
|
209
|
+
return fail('must be an absolute path');
|
|
210
|
+
if (raw.split(/[\\/]/).includes('..'))
|
|
211
|
+
return fail('must not contain ..');
|
|
212
|
+
if (env.authDirProblem)
|
|
213
|
+
return fail(env.authDirProblem);
|
|
214
|
+
const resolved = path.resolve(raw);
|
|
215
|
+
if (path.extname(resolved).toLowerCase() !== '.json')
|
|
216
|
+
return fail('must end in .json');
|
|
217
|
+
const realAuth = realpathDeep(env.authDir);
|
|
218
|
+
const real = realpathDeep(resolved);
|
|
219
|
+
if (!inside(real, realAuth) || real === realAuth)
|
|
220
|
+
return fail(`is outside the auth directory (${env.authDir}); symbolic links are followed`);
|
|
221
|
+
for (const dir of env.forbidden) {
|
|
222
|
+
if (inside(real, realpathDeep(dir)))
|
|
223
|
+
return fail(`is inside ${dir}, which holds other plugin data`);
|
|
224
|
+
}
|
|
225
|
+
let stat;
|
|
226
|
+
try {
|
|
227
|
+
stat = fs.lstatSync(resolved);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
stat = undefined;
|
|
231
|
+
}
|
|
232
|
+
if (stat?.isSymbolicLink())
|
|
233
|
+
return fail('must not be a symbolic link');
|
|
234
|
+
if (stat && !stat.isFile())
|
|
235
|
+
return fail('exists and is not a regular file');
|
|
236
|
+
if (takenByOthers.some(other => inside(realpathDeep(other), real) && inside(real, realpathDeep(other))))
|
|
237
|
+
return fail('is already the state file of another profile');
|
|
238
|
+
return resolved;
|
|
239
|
+
}
|
|
240
|
+
/** Whether the auth directory overlaps the directories that hold downloads or screenshots (then no agent path is trusted). */
|
|
241
|
+
export function authDirConflict(authDir, forbidden) {
|
|
242
|
+
const real = realpathDeep(authDir);
|
|
243
|
+
for (const dir of forbidden) {
|
|
244
|
+
const other = realpathDeep(dir);
|
|
245
|
+
if (inside(real, other) || inside(other, real))
|
|
246
|
+
return `the auth directory overlaps ${dir}`;
|
|
247
|
+
}
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
// -- usage policy --
|
|
251
|
+
/** How each throttle value moves when it gets stricter. */
|
|
252
|
+
const STRICTER = {
|
|
253
|
+
minDelayMs: 'higher', maxConcurrency: 'lower', burst: 'lower', maxPagesPerRun: 'lower',
|
|
254
|
+
maxDepth: 'lower', retryLimit: 'lower', backoffBaseMs: 'higher', cooldownMs: 'higher',
|
|
255
|
+
};
|
|
256
|
+
export function checkUsagePatch(raw) {
|
|
257
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
258
|
+
throw new ActionArgError('usage policy values must be an object of numbers');
|
|
259
|
+
const patch = {};
|
|
260
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
261
|
+
if (value === undefined)
|
|
262
|
+
continue;
|
|
263
|
+
const bounds = USAGE_POLICY_BOUNDS[key];
|
|
264
|
+
if (!bounds || !Object.hasOwn(USAGE_POLICY_BOUNDS, key))
|
|
265
|
+
throw new ActionArgError(`unknown usage policy key "${key}" (allowed: ${USAGE_POLICY_KEYS.join(', ')})`);
|
|
266
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < bounds[0] || value > bounds[1]) {
|
|
267
|
+
throw new ActionArgError(`usagePolicy.${key} must be an integer from ${bounds[0]} to ${bounds[1]}`);
|
|
268
|
+
}
|
|
269
|
+
patch[key] = value;
|
|
270
|
+
}
|
|
271
|
+
return patch;
|
|
272
|
+
}
|
|
273
|
+
export function emptyOverlay() {
|
|
274
|
+
return { version: 1, revision: 0, updatedAt: new Date(0).toISOString(), authProfiles: {}, usagePolicy: {}, audit: [] };
|
|
275
|
+
}
|
|
276
|
+
const cloneProfile = (profile) => ({ storageStatePath: profile.storageStatePath, allowedDomains: [...profile.allowedDomains ?? []], persistState: profile.persistState ?? false });
|
|
277
|
+
function nullRecord() { return Object.create(null); }
|
|
278
|
+
/** The baseline with the overlay merged on top. Never mutates its inputs. */
|
|
279
|
+
export function mergeOverlay(baseline, overlay) {
|
|
280
|
+
const profiles = nullRecord();
|
|
281
|
+
for (const [id, profile] of Object.entries(baseline.authProfiles))
|
|
282
|
+
profiles[id] = cloneProfile(profile);
|
|
283
|
+
for (const [id, entry] of Object.entries(overlay.authProfiles)) {
|
|
284
|
+
if (entry === null) {
|
|
285
|
+
delete profiles[id];
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const base = profiles[id];
|
|
289
|
+
profiles[id] = {
|
|
290
|
+
storageStatePath: entry.storageStatePath ?? base?.storageStatePath ?? '',
|
|
291
|
+
allowedDomains: [...entry.allowedDomains ?? base?.allowedDomains ?? []],
|
|
292
|
+
persistState: entry.persistState ?? base?.persistState ?? false,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return { authProfiles: profiles, usagePolicy: { ...baseline.usagePolicy, ...overlay.usagePolicy } };
|
|
296
|
+
}
|
|
297
|
+
const sortedBases = (domains) => new Map(domains.map(domain => [domainBase(domain), domain]));
|
|
298
|
+
export function diffPolicy(before, after) {
|
|
299
|
+
const profiles = [];
|
|
300
|
+
const lines = [];
|
|
301
|
+
let loosen = false;
|
|
302
|
+
let tighten = false;
|
|
303
|
+
const ids = new Set([...Object.keys(before.authProfiles), ...Object.keys(after.authProfiles)]);
|
|
304
|
+
for (const id of [...ids].sort()) {
|
|
305
|
+
const was = before.authProfiles[id];
|
|
306
|
+
const now = after.authProfiles[id];
|
|
307
|
+
if (was && !now) {
|
|
308
|
+
profiles.push({ id, change: 'removed', domainsAdded: [], domainsRemoved: [...was.allowedDomains], });
|
|
309
|
+
lines.push(`remove auth profile ${id} (had: ${was.allowedDomains.join(', ')})`);
|
|
310
|
+
tighten = true;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!was && now) {
|
|
314
|
+
profiles.push({ id, change: 'added', domainsAdded: [...now.allowedDomains], domainsRemoved: [], persistState: { before: false, after: now.persistState ?? false }, storageStatePath: { before: '', after: now.storageStatePath } });
|
|
315
|
+
lines.push(`create auth profile ${id}: domains ${now.allowedDomains.join(', ')}; persistState=${now.persistState ?? false}; state file ${now.storageStatePath}`);
|
|
316
|
+
loosen = true;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (!was || !now)
|
|
320
|
+
continue;
|
|
321
|
+
const wasBases = sortedBases(was.allowedDomains);
|
|
322
|
+
const nowBases = sortedBases(now.allowedDomains);
|
|
323
|
+
const added = [...nowBases].filter(([base]) => !wasBases.has(base)).map(([, entry]) => entry);
|
|
324
|
+
const removed = [...wasBases].filter(([base]) => !nowBases.has(base)).map(([, entry]) => entry);
|
|
325
|
+
const diff = { id, change: 'changed', domainsAdded: added, domainsRemoved: removed };
|
|
326
|
+
if ((was.persistState ?? false) !== (now.persistState ?? false))
|
|
327
|
+
diff.persistState = { before: was.persistState ?? false, after: now.persistState ?? false };
|
|
328
|
+
if (was.storageStatePath !== now.storageStatePath)
|
|
329
|
+
diff.storageStatePath = { before: was.storageStatePath, after: now.storageStatePath };
|
|
330
|
+
if (added.length || removed.length || diff.persistState || diff.storageStatePath) {
|
|
331
|
+
profiles.push(diff);
|
|
332
|
+
if (added.length) {
|
|
333
|
+
lines.push(`profile ${id}: allow ${added.join(', ')}`);
|
|
334
|
+
loosen = true;
|
|
335
|
+
}
|
|
336
|
+
if (removed.length) {
|
|
337
|
+
lines.push(`profile ${id}: stop allowing ${removed.join(', ')}`);
|
|
338
|
+
tighten = true;
|
|
339
|
+
}
|
|
340
|
+
if (diff.persistState) {
|
|
341
|
+
lines.push(`profile ${id}: persistState ${diff.persistState.before} -> ${diff.persistState.after}`);
|
|
342
|
+
if (diff.persistState.after)
|
|
343
|
+
loosen = true;
|
|
344
|
+
else
|
|
345
|
+
tighten = true;
|
|
346
|
+
}
|
|
347
|
+
if (diff.storageStatePath) {
|
|
348
|
+
lines.push(`profile ${id}: state file ${diff.storageStatePath.before} -> ${diff.storageStatePath.after}`);
|
|
349
|
+
loosen = true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const usage = [];
|
|
354
|
+
for (const key of USAGE_POLICY_KEYS) {
|
|
355
|
+
const was = before.usagePolicy[key];
|
|
356
|
+
const now = after.usagePolicy[key];
|
|
357
|
+
if (was === now)
|
|
358
|
+
continue;
|
|
359
|
+
const stricter = STRICTER[key] === 'higher' ? now > was : now < was;
|
|
360
|
+
usage.push({ key, before: was, after: now, direction: stricter ? 'tighten' : 'loosen' });
|
|
361
|
+
lines.push(`usagePolicy.${key}: ${was} -> ${now} (${stricter ? 'stricter' : 'looser'})`);
|
|
362
|
+
if (stricter)
|
|
363
|
+
tighten = true;
|
|
364
|
+
else
|
|
365
|
+
loosen = true;
|
|
366
|
+
}
|
|
367
|
+
return { profiles, usage, direction: loosen ? 'loosen' : tighten ? 'tighten' : 'none', lines };
|
|
368
|
+
}
|
|
369
|
+
export class PolicyConflictError extends Error {
|
|
370
|
+
constructor(message) {
|
|
371
|
+
super(message);
|
|
372
|
+
this.name = 'PolicyConflictError';
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
export function checksumOf(overlay) {
|
|
376
|
+
const canonical = JSON.stringify({ version: overlay.version, revision: overlay.revision, updatedAt: overlay.updatedAt, authProfiles: overlay.authProfiles, usagePolicy: overlay.usagePolicy, audit: overlay.audit });
|
|
377
|
+
return crypto.createHash('sha256').update(canonical).digest('hex');
|
|
378
|
+
}
|
|
379
|
+
export class PolicyOverlayStore {
|
|
380
|
+
options;
|
|
381
|
+
overlay = emptyOverlay();
|
|
382
|
+
signature = '';
|
|
383
|
+
problem;
|
|
384
|
+
pathEnv;
|
|
385
|
+
constructor(options) {
|
|
386
|
+
this.options = options;
|
|
387
|
+
const forbidden = [options.snapshotDir, options.downloadDir];
|
|
388
|
+
const conflict = authDirConflict(options.authDir, forbidden);
|
|
389
|
+
this.pathEnv = { authDir: options.authDir, forbidden, ...conflict ? { authDirProblem: conflict } : {} };
|
|
390
|
+
this.reload(true);
|
|
391
|
+
}
|
|
392
|
+
get file() { return this.options.file; }
|
|
393
|
+
get authDir() { return this.options.authDir; }
|
|
394
|
+
get allowPrivateHosts() { return this.options.allowPrivateHosts; }
|
|
395
|
+
get baseline() { return this.options.baseline; }
|
|
396
|
+
/** Why the overlay file was ignored, if it was. */
|
|
397
|
+
get warning() { return this.problem; }
|
|
398
|
+
current() { return this.overlay; }
|
|
399
|
+
effective() { return mergeOverlay(this.options.baseline, this.overlay); }
|
|
400
|
+
stat() {
|
|
401
|
+
try {
|
|
402
|
+
const stat = fs.statSync(this.options.file);
|
|
403
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return 'missing';
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Re-read the file when it changed on disk. Returns whether the effective policy may have changed. */
|
|
410
|
+
refresh() {
|
|
411
|
+
return this.stat() === this.signature ? false : this.reload(false);
|
|
412
|
+
}
|
|
413
|
+
reload(initial) {
|
|
414
|
+
const signature = this.stat();
|
|
415
|
+
this.signature = signature;
|
|
416
|
+
const previous = JSON.stringify(this.overlay);
|
|
417
|
+
const previousProblem = this.problem;
|
|
418
|
+
this.problem = undefined;
|
|
419
|
+
if (signature === 'missing') {
|
|
420
|
+
this.overlay = emptyOverlay();
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
try {
|
|
424
|
+
this.overlay = this.parse(fs.readFileSync(this.options.file, 'utf8'));
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
// Not fail-open: the baseline applies and the problem is visible. The file is left in place for inspection.
|
|
428
|
+
this.overlay = emptyOverlay();
|
|
429
|
+
this.problem = `policy overlay ${this.options.file} was ignored (the baseline configuration applies): ${String(error instanceof Error ? error.message : error).slice(0, 300)}. Clear it with runtime.reset_policy or the settings card.`;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return initial || previous !== JSON.stringify(this.overlay) || previousProblem !== this.problem;
|
|
433
|
+
}
|
|
434
|
+
/** Strict parse: unknown keys, wrong types, a failed checksum or any value the validators refuse reject the whole file. */
|
|
435
|
+
parse(text) {
|
|
436
|
+
let raw;
|
|
437
|
+
try {
|
|
438
|
+
raw = JSON.parse(text);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
throw new Error('the file is not valid JSON');
|
|
442
|
+
}
|
|
443
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
444
|
+
throw new Error('the file is not an object');
|
|
445
|
+
const allowed = new Set(['version', 'revision', 'updatedAt', 'authProfiles', 'usagePolicy', 'audit', 'checksum']);
|
|
446
|
+
for (const key of Object.keys(raw))
|
|
447
|
+
if (!allowed.has(key))
|
|
448
|
+
throw new Error(`unknown key "${key}"`);
|
|
449
|
+
if (raw.version !== 1)
|
|
450
|
+
throw new Error('unsupported version');
|
|
451
|
+
if (!Number.isInteger(raw.revision) || raw.revision < 0)
|
|
452
|
+
throw new Error('bad revision');
|
|
453
|
+
if (typeof raw.updatedAt !== 'string')
|
|
454
|
+
throw new Error('bad updatedAt');
|
|
455
|
+
if (!raw.authProfiles || typeof raw.authProfiles !== 'object' || Array.isArray(raw.authProfiles))
|
|
456
|
+
throw new Error('bad authProfiles');
|
|
457
|
+
if (!raw.usagePolicy || typeof raw.usagePolicy !== 'object' || Array.isArray(raw.usagePolicy))
|
|
458
|
+
throw new Error('bad usagePolicy');
|
|
459
|
+
if (!Array.isArray(raw.audit) || raw.audit.length > MAX_AUDIT_ENTRIES)
|
|
460
|
+
throw new Error('bad audit');
|
|
461
|
+
const overlay = { version: 1, revision: raw.revision, updatedAt: raw.updatedAt, authProfiles: nullRecord(), usagePolicy: {}, audit: raw.audit };
|
|
462
|
+
if (typeof raw.checksum !== 'string' || raw.checksum !== checksumOf({ ...overlay, authProfiles: raw.authProfiles, usagePolicy: raw.usagePolicy }))
|
|
463
|
+
throw new Error('the checksum does not match: the file was edited outside the plugin');
|
|
464
|
+
for (const entry of raw.audit) {
|
|
465
|
+
if (!entry || typeof entry !== 'object' || typeof entry.action !== 'string' || typeof entry.reason !== 'string' || !Array.isArray(entry.changes))
|
|
466
|
+
throw new Error('bad audit entry');
|
|
467
|
+
}
|
|
468
|
+
overlay.usagePolicy = checkUsagePatch(raw.usagePolicy);
|
|
469
|
+
if (Object.keys(raw.authProfiles).length > MAX_PROFILES)
|
|
470
|
+
throw new Error('too many profiles');
|
|
471
|
+
const env = { allowPrivateHosts: this.options.allowPrivateHosts };
|
|
472
|
+
for (const [id, entry] of Object.entries(raw.authProfiles)) {
|
|
473
|
+
checkProfileId(id);
|
|
474
|
+
if (entry === null) {
|
|
475
|
+
overlay.authProfiles[id] = null;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (typeof entry !== 'object' || Array.isArray(entry))
|
|
479
|
+
throw new Error(`bad entry for ${id}`);
|
|
480
|
+
for (const key of Object.keys(entry))
|
|
481
|
+
if (!['allowedDomains', 'persistState', 'storageStatePath'].includes(key))
|
|
482
|
+
throw new Error(`unknown key "${key}" in ${id}`);
|
|
483
|
+
const clean = {};
|
|
484
|
+
if (entry.allowedDomains !== undefined) {
|
|
485
|
+
const normalized = normalizeAllowedDomains(entry.allowedDomains, env);
|
|
486
|
+
if (JSON.stringify(normalized) !== JSON.stringify(entry.allowedDomains))
|
|
487
|
+
throw new Error(`domains of ${id} are not in normalized form`);
|
|
488
|
+
clean.allowedDomains = normalized;
|
|
489
|
+
}
|
|
490
|
+
if (entry.persistState !== undefined) {
|
|
491
|
+
if (typeof entry.persistState !== 'boolean')
|
|
492
|
+
throw new Error(`persistState of ${id} is not a boolean`);
|
|
493
|
+
clean.persistState = entry.persistState;
|
|
494
|
+
}
|
|
495
|
+
if (entry.storageStatePath !== undefined) {
|
|
496
|
+
const baselinePath = this.options.baseline.authProfiles[id]?.storageStatePath;
|
|
497
|
+
// A path equal to the baseline's was not chosen by the overlay; any other must be a valid auth-directory file.
|
|
498
|
+
clean.storageStatePath = entry.storageStatePath === baselinePath ? entry.storageStatePath : checkStorageStatePath(entry.storageStatePath, this.pathEnv);
|
|
499
|
+
}
|
|
500
|
+
overlay.authProfiles[id] = clean;
|
|
501
|
+
}
|
|
502
|
+
for (const [id, entry] of Object.entries(overlay.authProfiles)) {
|
|
503
|
+
if (entry && !this.options.baseline.authProfiles[id] && (!entry.allowedDomains || !entry.storageStatePath))
|
|
504
|
+
throw new Error(`new profile ${id} is incomplete`);
|
|
505
|
+
}
|
|
506
|
+
return overlay;
|
|
507
|
+
}
|
|
508
|
+
/** Compute a change without writing anything. Throws {@link ActionArgError} for an invalid change. */
|
|
509
|
+
plan(change) {
|
|
510
|
+
// Not INVALID_ARGS: the arguments are fine, the stored overlay is what is broken. CAPABILITY_UNAVAILABLE ("not set up
|
|
511
|
+
// here") with a hint that names the way out.
|
|
512
|
+
if (this.problem && change.kind !== 'reset') {
|
|
513
|
+
throw new ActionUnavailableError(`the policy overlay file is unreadable and is being ignored (the baseline applies), so the policy cannot be changed: ${this.problem}`, 'Nothing was changed. Call runtime.reset_policy {scope:"all", reason:...} (or ask the user to use "Clear overlay" in the settings card) to move the bad file aside, then repeat this change.');
|
|
514
|
+
}
|
|
515
|
+
const before = this.effective();
|
|
516
|
+
const next = { ...this.overlay, authProfiles: Object.assign(nullRecord(), this.overlay.authProfiles), usagePolicy: { ...this.overlay.usagePolicy } };
|
|
517
|
+
const env = { allowPrivateHosts: this.options.allowPrivateHosts };
|
|
518
|
+
const baselineProfiles = this.options.baseline.authProfiles;
|
|
519
|
+
switch (change.kind) {
|
|
520
|
+
case 'setAuthProfile': {
|
|
521
|
+
const id = checkProfileId(change.id);
|
|
522
|
+
const current = before.authProfiles[id];
|
|
523
|
+
if (!current && Object.keys(before.authProfiles).length >= MAX_PROFILES)
|
|
524
|
+
throw new ActionArgError(`at most ${MAX_PROFILES} auth profiles are allowed`);
|
|
525
|
+
const known = ['allowedDomains', 'persistState', 'storageStatePath'].filter(key => change[key] !== undefined);
|
|
526
|
+
if (!current && change.allowedDomains === undefined)
|
|
527
|
+
throw new ActionArgError(`creating profile ${id} requires allowedDomains`);
|
|
528
|
+
if (known.length === 0)
|
|
529
|
+
throw new ActionArgError('give at least one of allowedDomains, persistState, storageStatePath');
|
|
530
|
+
const domains = change.allowedDomains !== undefined ? normalizeAllowedDomains(change.allowedDomains, env) : current.allowedDomains;
|
|
531
|
+
let persist = current?.persistState ?? false;
|
|
532
|
+
if (change.persistState !== undefined) {
|
|
533
|
+
if (typeof change.persistState !== 'boolean')
|
|
534
|
+
throw new ActionArgError('persistState must be true or false');
|
|
535
|
+
persist = change.persistState;
|
|
536
|
+
}
|
|
537
|
+
let storage = current?.storageStatePath ?? path.join(this.options.authDir, `${id}.json`);
|
|
538
|
+
const others = Object.entries(before.authProfiles).filter(([other]) => other !== id).map(([, profile]) => profile.storageStatePath);
|
|
539
|
+
if (change.storageStatePath !== undefined)
|
|
540
|
+
storage = checkStorageStatePath(change.storageStatePath, this.pathEnv, others);
|
|
541
|
+
else if (!current)
|
|
542
|
+
storage = checkStorageStatePath(storage, this.pathEnv, others);
|
|
543
|
+
// A profile that only reads its state needs the file to exist; the agent cannot create it, so say so now.
|
|
544
|
+
if (!persist && !fs.existsSync(storage)) {
|
|
545
|
+
throw new ActionArgError(`the state file ${storage} does not exist and persistState is false, so the profile could never load a login state`, 'Ask the user to place the state file there, or set persistState=true to have it created from the session after login.');
|
|
546
|
+
}
|
|
547
|
+
// Store only what differs from the baseline (everything for a profile the baseline does not have).
|
|
548
|
+
const base = baselineProfiles[id];
|
|
549
|
+
const entry = {};
|
|
550
|
+
if (!base || JSON.stringify(domains.map(domainBase).sort()) !== JSON.stringify(base.allowedDomains.map(domainBase).sort()) || JSON.stringify(domains) !== JSON.stringify(base.allowedDomains))
|
|
551
|
+
entry.allowedDomains = domains;
|
|
552
|
+
if (!base || persist !== (base.persistState ?? false))
|
|
553
|
+
entry.persistState = persist;
|
|
554
|
+
if (!base || storage !== base.storageStatePath)
|
|
555
|
+
entry.storageStatePath = storage;
|
|
556
|
+
if (base && Object.keys(entry).length === 0)
|
|
557
|
+
delete next.authProfiles[id];
|
|
558
|
+
else
|
|
559
|
+
next.authProfiles[id] = entry;
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
case 'removeAuthProfile': {
|
|
563
|
+
const id = checkProfileId(change.id);
|
|
564
|
+
if (!before.authProfiles[id])
|
|
565
|
+
throw new ActionArgError(`unknown auth profile: ${id}`);
|
|
566
|
+
if (baselineProfiles[id])
|
|
567
|
+
next.authProfiles[id] = null;
|
|
568
|
+
else
|
|
569
|
+
delete next.authProfiles[id];
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
case 'setUsagePolicy': {
|
|
573
|
+
const patch = checkUsagePatch(change.values);
|
|
574
|
+
if (Object.keys(patch).length === 0)
|
|
575
|
+
throw new ActionArgError(`give at least one of: ${USAGE_POLICY_KEYS.join(', ')}`);
|
|
576
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
577
|
+
if (value === this.options.baseline.usagePolicy[key])
|
|
578
|
+
delete next.usagePolicy[key];
|
|
579
|
+
else
|
|
580
|
+
next.usagePolicy[key] = value;
|
|
581
|
+
}
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
case 'reset': {
|
|
585
|
+
if (change.scope === 'all') {
|
|
586
|
+
next.authProfiles = nullRecord();
|
|
587
|
+
next.usagePolicy = {};
|
|
588
|
+
}
|
|
589
|
+
else if (change.scope === 'usagePolicy')
|
|
590
|
+
next.usagePolicy = {};
|
|
591
|
+
else {
|
|
592
|
+
const id = checkProfileId(change.id);
|
|
593
|
+
if (!Object.hasOwn(next.authProfiles, id))
|
|
594
|
+
throw new ActionArgError(`profile ${id} has no overlay to reset`);
|
|
595
|
+
delete next.authProfiles[id];
|
|
596
|
+
}
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const after = mergeOverlay(this.options.baseline, next);
|
|
601
|
+
return { before, after, overlay: next, diff: diffPolicy(before, after) };
|
|
602
|
+
}
|
|
603
|
+
/** Plan and write a change: re-reads the file first, checks the revision, writes atomically (0600) and appends the audit entry. */
|
|
604
|
+
apply(change, meta) {
|
|
605
|
+
// A reset must work on a corrupt file; anything else was refused by plan() above.
|
|
606
|
+
this.refresh();
|
|
607
|
+
if (this.problem && change.kind === 'reset')
|
|
608
|
+
this.quarantine();
|
|
609
|
+
if (meta.expectedRevision !== undefined && meta.expectedRevision !== this.overlay.revision) {
|
|
610
|
+
throw new PolicyConflictError(`the policy overlay is at revision ${this.overlay.revision}, not ${meta.expectedRevision}: it changed since it was read; call inspect.policy and decide again`);
|
|
611
|
+
}
|
|
612
|
+
const planned = this.plan(change);
|
|
613
|
+
const audit = {
|
|
614
|
+
revision: this.overlay.revision + 1,
|
|
615
|
+
at: new Date().toISOString(),
|
|
616
|
+
source: meta.source,
|
|
617
|
+
...meta.session ? { session: meta.session } : {},
|
|
618
|
+
action: meta.action,
|
|
619
|
+
reason: checkReason(meta.reason),
|
|
620
|
+
direction: planned.diff.direction,
|
|
621
|
+
changes: planned.diff.lines.slice(0, 20),
|
|
622
|
+
};
|
|
623
|
+
const written = {
|
|
624
|
+
...planned.overlay,
|
|
625
|
+
revision: audit.revision,
|
|
626
|
+
updatedAt: audit.at,
|
|
627
|
+
audit: [...this.overlay.audit, audit].slice(-MAX_AUDIT_ENTRIES),
|
|
628
|
+
};
|
|
629
|
+
fs.mkdirSync(path.dirname(this.options.file), { recursive: true, mode: 0o700 });
|
|
630
|
+
const temporary = `${this.options.file}.tmp-${crypto.randomUUID().slice(0, 8)}`;
|
|
631
|
+
fs.writeFileSync(temporary, JSON.stringify({ ...written, checksum: checksumOf(written) }, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
632
|
+
fs.renameSync(temporary, this.options.file);
|
|
633
|
+
this.overlay = written;
|
|
634
|
+
this.signature = this.stat();
|
|
635
|
+
this.problem = undefined;
|
|
636
|
+
return { ...planned, overlay: written, revision: written.revision, audit };
|
|
637
|
+
}
|
|
638
|
+
/** Move an unreadable file aside (kept for inspection) so a fresh overlay can start. */
|
|
639
|
+
quarantine() {
|
|
640
|
+
try {
|
|
641
|
+
fs.renameSync(this.options.file, `${this.options.file}.corrupt-${Date.now()}`);
|
|
642
|
+
}
|
|
643
|
+
catch { /* nothing to move */ }
|
|
644
|
+
this.overlay = emptyOverlay();
|
|
645
|
+
this.problem = undefined;
|
|
646
|
+
this.signature = this.stat();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
//# sourceMappingURL=policy-overlay.js.map
|