@twinsearth/w2m-dsh-plugin 1.2.3 → 1.2.6

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.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The permission matrix: which fleet role may perform which action.
3
+ *
4
+ * ## Why a table, and why refusals are named
5
+ *
6
+ * A role check scattered across call sites drifts: one route checks, another forgets, and the matrix exists only in a document.
7
+ * This table is the single answer, and a refusal always names the action, the role required and the role held -- an
8
+ * "access denied" without those three facts is a support ticket, not a diagnostic.
9
+ *
10
+ * ## Why the levels are ordinal
11
+ *
12
+ * Roles form an order (observer < worker < owner). Stating a minimum level rather than enumerating every allowed role means a
13
+ * new role slots in between two existing ones without rewriting every action.
14
+ */
15
+ export const FLEET_ACTIONS = Object.freeze(['read_private', 'vote', 'join', 'dispatch', 'approve', 'manage_members']);
16
+
17
+ /** Lower number = fewer rights. */
18
+ export const ROLE_LEVEL = Object.freeze({ observer: 0, worker: 1, owner: 2 });
19
+
20
+ /** The minimum level an action needs. `manage_members` is owner-only; approving is owner-only for the same reason. */
21
+ export const ACTION_MIN_LEVEL = Object.freeze({
22
+ read_private: 0,
23
+ vote: 1,
24
+ join: 1,
25
+ dispatch: 1,
26
+ approve: 2,
27
+ manage_members: 2,
28
+ });
29
+
30
+ /** The role a level maps back to, for a refusal that says which role would have been enough. */
31
+ export function roleForLevel(level) {
32
+ const best = Object.entries(ROLE_LEVEL).filter(([, value]) => value === level)[0];
33
+ return best === undefined ? null : best[0];
34
+ }
35
+
36
+ /**
37
+ * @param {{role?: string|null, action: string}} input
38
+ * @returns {{ok: true, action: string, role: string}} | {ok: false, code: 'FORBIDDEN_ACTION', action: string, required_level: number, current_level: number, required_role: string|null, current_role: string}}
39
+ */
40
+ export function can({ role = null, action } = {}) {
41
+ const currentRole = typeof role === 'string' && role in ROLE_LEVEL ? role : 'worker'; // unknown device = worker, never owner
42
+ if (!FLEET_ACTIONS.includes(action)) {
43
+ return {
44
+ ok: false, code: 'FORBIDDEN_ACTION', action, required_level: null, current_level: ROLE_LEVEL[currentRole],
45
+ required_role: null, current_role: currentRole,
46
+ };
47
+ }
48
+ const requiredLevel = ACTION_MIN_LEVEL[action];
49
+ const currentLevel = ROLE_LEVEL[currentRole];
50
+ if (currentLevel >= requiredLevel) return { ok: true, action, role: currentRole };
51
+ return {
52
+ ok: false,
53
+ code: 'FORBIDDEN_ACTION',
54
+ action,
55
+ required_level: requiredLevel,
56
+ current_level: currentLevel,
57
+ required_role: roleForLevel(requiredLevel),
58
+ current_role: currentRole,
59
+ };
60
+ }
61
+
62
+ /** Every action with its verdict for one role -- the board an operator reads. */
63
+ export function matrixFor(role) {
64
+ return FLEET_ACTIONS.map((action) => ({ action, ...can({ role, action }) }));
65
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Output redaction: secrets a command prints are replaced before they leave this machine.
3
+ *
4
+ * ## Why a replacement, not a deletion
5
+ *
6
+ * Deleting the line would keep the secret out of the envelope and also destroy the evidence: line numbers and structure are
7
+ * what make a failure reproducible. So each match becomes [redacted:class] in place.
8
+ *
9
+ * ## Why nothing here touches a hash
10
+ *
11
+ * stdout_sha256 and stdout_bytes cover the RAW bytes and are computed by the executor. Redacting the text must not feed back
12
+ * into them: the envelope's hashes stay evidence, and only the human-readable heads/tails are filtered. The same is true of
13
+ * blobs -- a blob's name is the hash of its bytes, and rewriting them would make the name a lie.
14
+ *
15
+ * ## Why it can be turned off
16
+ *
17
+ * An operator debugging a credential problem needs the original. Turning it off is allowed and SAYS SO (unredacted: true in the
18
+ * envelope), because a redaction that silently does nothing is worse than no redaction at all.
19
+ */
20
+
21
+ /** Each rule: a class name and the pattern that finds it. Order matters only for the private-key block. */
22
+ const RULES = Object.freeze([
23
+ { cls: 'private-key', pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g },
24
+ { cls: 'api-key', pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g },
25
+ { cls: 'api-key', pattern: /\bnpm_[A-Za-z0-9]{20,}\b/g },
26
+ { cls: 'api-key', pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g },
27
+ { cls: 'api-key', pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
28
+ { cls: 'api-key', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
29
+ { cls: 'aws-key', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
30
+ ]);
31
+
32
+ /**
33
+ * @param {string} text
34
+ * @returns {{text: string, replaced: Array<{cls: string, count: number}>}}
35
+ */
36
+ export function redactText(text) {
37
+ if (typeof text !== 'string' || text === '') return { text: typeof text === 'string' ? text : '', replaced: [] };
38
+ let out = text;
39
+ const counts = new Map();
40
+ for (const rule of RULES) {
41
+ let hits = 0;
42
+ out = out.replace(rule.pattern, () => { hits += 1; return '[redacted:' + rule.cls + ']'; });
43
+ if (hits > 0) counts.set(rule.cls, (counts.get(rule.cls) ?? 0) + hits);
44
+ }
45
+ return { text: out, replaced: [...counts.entries()].map(([cls, count]) => ({ cls, count })) };
46
+ }
47
+
48
+ /** The envelope fields that carry command output and are therefore filtered. */
49
+ export const REDACTABLE_FIELDS = Object.freeze(['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail']);
50
+
51
+ /**
52
+ * @param {object} envelope
53
+ * @param {{enabled?: boolean}} [options]
54
+ * @returns {{envelope: object, redacted: Array<{field: string, cls: string, count: number}>}}
55
+ */
56
+ export function redactEnvelope(envelope, { enabled = true } = {}) {
57
+ if (envelope === null || typeof envelope !== 'object') return { envelope, redacted: [] };
58
+ if (enabled !== true) return { envelope: { ...envelope, unredacted: true }, redacted: [] };
59
+ const next = { ...envelope };
60
+ const redacted = [];
61
+ for (const field of REDACTABLE_FIELDS) {
62
+ const value = next[field];
63
+ if (typeof value !== 'string' || value === '') continue;
64
+ const result = redactText(value);
65
+ next[field] = result.text;
66
+ for (const hit of result.replaced) redacted.push({ field, ...hit });
67
+ }
68
+ if (redacted.length > 0) next.redacted = redacted;
69
+ return { envelope: next, redacted };
70
+ }