@twinsearth/w2m-dsh-plugin 1.2.5 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +60 -0
- package/codex/mcp-servers.toml +1 -1
- package/package.json +8 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +4414 -4360
- package/src/agent/crypto.mjs +31 -0
- package/src/plugin/tools.mjs +152 -22
- package/src/relay/fleet-messages.mjs +17 -0
- package/src/relay/report.mjs +5 -0
- package/src/relay/server.mjs +21 -0
- package/src/relay/state.mjs +55 -2
- package/src/util/chat-crypto.mjs +98 -0
- package/src/util/envelope-signature.mjs +69 -0
- package/src/util/policy-matrix.mjs +65 -0
- package/src/util/redact.mjs +70 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Envelope signatures: what a machine signed, and how a reader checks it.
|
|
3
|
+
*
|
|
4
|
+
* ## Why the payload is exactly four fields
|
|
5
|
+
*
|
|
6
|
+
* A signature over "the whole envelope" is impossible to verify reproducibly: the envelope carries hashes, timestamps and
|
|
7
|
+
* transport facts that change between the signing machine and the reader. So the machine signs the four facts that identify
|
|
8
|
+
* THE COMMAND: task id, argv hash (the envelope's command_hash), machine id, and when it signed. Everything else in the
|
|
9
|
+
* envelope is covered indirectly through those, and a reader verifies exactly this tuple.
|
|
10
|
+
*
|
|
11
|
+
* ## Why verification returns a status rather than throwing
|
|
12
|
+
*
|
|
13
|
+
* "unsigned" is not an error -- a machine running an older build, or one configured without a device key, is a fact to report.
|
|
14
|
+
* "bad_signature" is the interesting case: the data does not match the signature, which is exactly what tampering looks like.
|
|
15
|
+
* A caller must see the difference, so both are values.
|
|
16
|
+
*/
|
|
17
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
/** The DER prefix for an Ed25519 SubjectPublicKeyInfo; the 32 raw key bytes follow. */
|
|
20
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The canonical bytes a machine signs. Sorted keys, no whitespace, so the same tuple always produces the same bytes.
|
|
24
|
+
*
|
|
25
|
+
* @param {{task_id: string, argv_hash: string, machine_id: string, ts: string}} input
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function canonicalSignedPayload({ task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts }) {
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
argv_hash: String(argvHash ?? ''),
|
|
31
|
+
machine_id: String(machineId ?? ''),
|
|
32
|
+
task_id: String(taskId ?? ''),
|
|
33
|
+
ts: String(ts ?? ''),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string|null} publicKeyBase64
|
|
39
|
+
* @returns {import('node:crypto').KeyObject|null}
|
|
40
|
+
*/
|
|
41
|
+
export function publicKeyFromBase64(publicKeyBase64) {
|
|
42
|
+
if (typeof publicKeyBase64 !== 'string' || publicKeyBase64 === '') return null;
|
|
43
|
+
try {
|
|
44
|
+
const raw = Buffer.from(publicKeyBase64, 'base64');
|
|
45
|
+
if (raw.length !== 32) return null;
|
|
46
|
+
return createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{signature?: object|null, task_id?: string, argv_hash?: string, machine_id?: string, ts?: string}} input
|
|
54
|
+
* @returns {'signed'|'unsigned'|'bad_signature'|'unsupported_algorithm'}
|
|
55
|
+
*/
|
|
56
|
+
export function verifyEnvelopeSignature({ signature = null, task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts } = {}) {
|
|
57
|
+
if (signature === null || signature === undefined || typeof signature !== 'object') return 'unsigned';
|
|
58
|
+
if (signature.alg !== 'ed25519') return 'unsupported_algorithm';
|
|
59
|
+
if (typeof signature.value !== 'string' || signature.value === '') return 'bad_signature';
|
|
60
|
+
const key = publicKeyFromBase64(signature.public_key);
|
|
61
|
+
if (key === null) return 'bad_signature';
|
|
62
|
+
const payload = canonicalSignedPayload({ task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts: signature.ts ?? ts });
|
|
63
|
+
try {
|
|
64
|
+
const ok = verify(null, Buffer.from(payload, 'utf8'), key, Buffer.from(signature.value, 'base64'));
|
|
65
|
+
return ok ? 'signed' : 'bad_signature';
|
|
66
|
+
} catch {
|
|
67
|
+
return 'bad_signature';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -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
|
+
}
|