@klarkxy/dsh-safe-auto 0.1.0-rc.6 → 0.2.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.
@@ -0,0 +1,159 @@
1
+ import { lstatSync, realpathSync } from 'node:fs';
2
+ import { isAbsolute, normalize } from 'node:path';
3
+
4
+ const DEPENDENCIES_TOOL = 'load_workspace_dependencies';
5
+ const MAX_PATH = 4096;
6
+ const MAX_COMMAND = 32768;
7
+ const attestations = new WeakMap();
8
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ const pathKey = value => process.platform === 'win32' ? normalize(value).toLowerCase() : normalize(value);
10
+ const absolutePath = value => typeof value === 'string' && value.length > 0 && value.length <= MAX_PATH &&
11
+ !/[\x00-\x1f]/.test(value) && isAbsolute(value) && !value.startsWith('\\\\') && !value.startsWith('//') &&
12
+ !value.split(/[\\/]/).includes('..');
13
+
14
+ /** Local host paths only. A symlink/junction alias is not an attested entry.
15
+ * This checks file identity, not executable safety, and grants no permission.
16
+ * No directory traversal or executable hashing is performed.
17
+ */
18
+ export function snapshotHostRuntimeFile(path) {
19
+ if (!absolutePath(path)) return undefined;
20
+ try {
21
+ const canonical = realpathSync.native(path);
22
+ const stat = lstatSync(path, { bigint: true });
23
+ if (!stat.isFile() || stat.isSymbolicLink() || pathKey(canonical) !== pathKey(path)) return undefined;
24
+ return Object.freeze({ path, identity: [canonical, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode].join(':') });
25
+ } catch { return undefined; }
26
+ }
27
+
28
+ function dependencyPair(value) {
29
+ if (!record(value) || Object.keys(value).some(key => !['python', 'node', 'pnpm', 'pythonPackages', 'nodePackages', 'pythonDistributions'].includes(key))) return undefined;
30
+ if (![value.python, value.pythonPackages, value.node, value.pnpm].every(absolutePath) ||
31
+ value.nodePackages !== undefined && !absolutePath(value.nodePackages) || !record(value.pythonDistributions)) return undefined;
32
+ if (pathKey(value.node) === pathKey(value.pnpm)) return undefined;
33
+ return { node: value.node, pnpm: value.pnpm };
34
+ }
35
+
36
+ /** Observe the native tools pipeline, never transcript/content text or replay.
37
+ * begin is called from tools/pre-execute; observe from tools/result; get from
38
+ * the shell guard. tools.get(global) and tools.get(agent) must be the SAME
39
+ * pinned definition throughout. Scope shadows and registration changes fail
40
+ * closed. Host plugins themselves remain part of the trusted host boundary.
41
+ */
42
+ export function createHostRuntimeRegistry(tools, { snapshot = snapshotHostRuntimeFile } = {}) {
43
+ let owner = tools?.get?.(DEPENDENCIES_TOOL);
44
+ let disposed = false;
45
+ const pending = new Map(), sessions = new WeakMap();
46
+ const definitionMatches = agent => !disposed && !!owner && tools?.get?.(DEPENDENCIES_TOOL) === owner &&
47
+ tools?.get?.(DEPENDENCIES_TOOL, agent) === owner;
48
+ const forgetPending = token => {
49
+ const item = pending.get(token);
50
+ if (item) { pending.delete(token); item.signal?.removeEventListener('abort', item.abort); }
51
+ return item;
52
+ };
53
+ const valid = (agent, entry) => {
54
+ if (sessions.get(agent) !== entry || !definitionMatches(agent)) return false;
55
+ try {
56
+ return entry.files.every(file => snapshot(file.path)?.identity === file.identity);
57
+ } catch { return false; }
58
+ };
59
+ return {
60
+ begin(exec) {
61
+ if (disposed) return;
62
+ if (exec?.name !== DEPENDENCIES_TOOL || !record(exec.agent) || typeof exec.token !== 'symbol' || exec.signal?.aborted) return;
63
+ forgetPending(exec.token);
64
+ owner ??= tools?.get?.(DEPENDENCIES_TOOL);
65
+ if (!definitionMatches(exec.agent) || !record(exec.arguments) || Object.keys(exec.arguments).length) return;
66
+ // Bound interrupted-call retention without relying on a lifecycle event.
67
+ if (pending.size >= 64) forgetPending(pending.keys().next().value);
68
+ const item = { agent: exec.agent, signal: exec.signal, abort: () => forgetPending(exec.token) };
69
+ pending.set(exec.token, item);
70
+ exec.signal?.addEventListener('abort', item.abort, { once: true });
71
+ },
72
+ observe(exec, result) {
73
+ if (disposed) return;
74
+ if (exec?.name !== DEPENDENCIES_TOOL || !record(exec.agent)) return;
75
+ const started = forgetPending(exec.token);
76
+ sessions.delete(exec.agent);
77
+ if (!started || started.agent !== exec.agent || exec.signal?.aborted || result?.isError !== false || !definitionMatches(exec.agent)) return;
78
+ const pair = dependencyPair(result.value);
79
+ if (!pair) return;
80
+ let files;
81
+ try { files = [snapshot(pair.node), snapshot(pair.pnpm)]; } catch { return; }
82
+ if (files.some(file => !file || typeof file.identity !== 'string')) return;
83
+ const runtime = Object.freeze({ ...pair, identity: Object.freeze(files.map(file => file.identity)) });
84
+ const entry = { runtime, files };
85
+ sessions.set(exec.agent, entry);
86
+ attestations.set(runtime, () => valid(exec.agent, entry));
87
+ },
88
+ get(exec) {
89
+ if (disposed) return undefined;
90
+ if (!record(exec?.agent) || exec.signal?.aborted) return undefined;
91
+ const entry = sessions.get(exec.agent);
92
+ if (!entry) return undefined;
93
+ if (!valid(exec.agent, entry)) { sessions.delete(exec.agent); return undefined; }
94
+ return entry.runtime;
95
+ },
96
+ dispose() {
97
+ disposed = true;
98
+ owner = undefined;
99
+ for (const token of pending.keys()) forgetPending(token);
100
+ },
101
+ };
102
+ }
103
+
104
+ /** A deliberately small single-invocation grammar, not a general shell parser.
105
+ * No interpolation, escapes, concatenated quotes, wrappers, redirections,
106
+ * pipelines, assignments or additional commands can earn an exemption.
107
+ * Single-quoted content is literal in both shells. For PowerShell, doubled
108
+ * quotes are deliberately unsupported; unsupported syntax keeps full policy.
109
+ */
110
+ function literalInvocation(command, tool) {
111
+ if (typeof command !== 'string' || !command.length || command.length > MAX_COMMAND || /[\x00-\x1f]/.test(command)) return undefined;
112
+ const powershell = tool === 'pwsh';
113
+ if (!powershell && tool !== 'bash') return undefined;
114
+ let cursor = 0;
115
+ const spaces = () => { while (command[cursor] === ' ') cursor++; };
116
+ spaces();
117
+ let callOperator = false;
118
+ if (powershell && command[cursor] === '&') { callOperator = true; cursor++; spaces(); }
119
+ const tokens = [];
120
+ while (cursor < command.length) {
121
+ const start = cursor, quote = command[cursor];
122
+ let value;
123
+ if (quote === "'" || quote === '"') {
124
+ cursor++;
125
+ const from = cursor;
126
+ while (cursor < command.length && command[cursor] !== quote) cursor++;
127
+ if (cursor === command.length) return undefined;
128
+ value = command.slice(from, cursor++);
129
+ if (quote === '"' && (powershell ? /[$`]/ : /[$`\\]/).test(value)) return undefined;
130
+ if (powershell && tokens.length === 0 && !callOperator) return undefined;
131
+ if (cursor < command.length && command[cursor] !== ' ') return undefined;
132
+ } else {
133
+ while (cursor < command.length && command[cursor] !== ' ') cursor++;
134
+ value = command.slice(start, cursor);
135
+ if (!(powershell ? /^[A-Za-z0-9_.\/:\\=-]+$/ : /^[A-Za-z0-9_.\/:=-]+$/).test(value)) return undefined;
136
+ }
137
+ tokens.push({ start, end: cursor, value });
138
+ spaces();
139
+ }
140
+ return tokens.length >= 2 ? tokens : undefined;
141
+ }
142
+
143
+ /** Return a screening-only projection. NEVER execute it or send it for review.
144
+ * Only the attested Node argv[0] + pnpm argv[1] pair is removed; every data
145
+ * argument remains verbatim. This does not classify pnpm or its scripts safe.
146
+ */
147
+ export function commandForPolicy(call) {
148
+ const command = call?.args?.command;
149
+ const runtime = call?.trustedRuntime;
150
+ const validate = runtime && attestations.get(runtime);
151
+ if (!validate || !validate()) return command;
152
+ const tokens = literalInvocation(command, call.tool);
153
+ if (!tokens || tokens[0].value !== runtime.node || tokens[1].value !== runtime.pnpm) return command;
154
+ // The validated PowerShell call operator is omitted from this projection:
155
+ // the policy's refusal decoder parses POSIX commands and otherwise misses
156
+ // quoted sensitive operands after a leading '&'. The real call is intact.
157
+ return '__DSH_HOST_NODE__' +
158
+ command.slice(tokens[0].end, tokens[1].start) + '__DSH_HOST_PNPM__' + command.slice(tokens[1].end);
159
+ }
@@ -0,0 +1,64 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ /** Runtime-local single-use requests. Only the authenticated UI route answers
4
+ * them; no automatic approval waterfall or model tool is consulted. The client
5
+ * and other same-process plugins remain trusted, not a physical-human proof.
6
+ */
7
+ export function createHumanApprovals({ now = () => performance.now(), wallNow = Date.now } = {}) {
8
+ const pending = new Map();
9
+ let closed = false;
10
+ function current(row) {
11
+ if (row.signal.aborted) return { kind: 'cancel', code: 'HUMAN_APPROVAL_CANCELLED' };
12
+ if (now() >= row.deadline) return { kind: 'deny', code: 'HUMAN_APPROVAL_EXPIRED' };
13
+ try { if (!row.valid()) return { kind: 'deny', code: 'GRANT_INVALIDATED' }; }
14
+ catch { return { kind: 'deny', code: 'GRANT_INVALIDATED' }; }
15
+ return undefined;
16
+ }
17
+ function settle(row, decision) {
18
+ if (pending.get(row.id) !== row) return false;
19
+ pending.delete(row.id);
20
+ clearInterval(row.timer);
21
+ row.signal.removeEventListener('abort', row.onAbort);
22
+ row.resolve(decision);
23
+ return true;
24
+ }
25
+ return {
26
+ request({ session, action, review, signal, timeoutMs, valid }) {
27
+ if (closed || pending.size >= 32) return Promise.resolve({ kind: 'deny', code: 'HUMAN_APPROVAL_UNAVAILABLE' });
28
+ return new Promise(resolve => {
29
+ const id = randomUUID(), deadline = now() + timeoutMs;
30
+ const row = { id, session, signal, valid, resolve, deadline,
31
+ view: { id, action: structuredClone(action), review: structuredClone(review), expiresAt: wallNow() + timeoutMs } };
32
+ row.onAbort = () => settle(row, { kind: 'cancel', code: 'HUMAN_APPROVAL_CANCELLED' });
33
+ pending.set(id, row);
34
+ row.timer = setInterval(() => { const invalid = current(row); if (invalid) settle(row, invalid); }, Math.min(250, timeoutMs));
35
+ signal.addEventListener('abort', row.onAbort, { once: true });
36
+ const invalid = current(row); if (invalid) settle(row, invalid);
37
+ });
38
+ },
39
+ list(session) {
40
+ const requests = [];
41
+ for (const row of pending.values()) {
42
+ const invalid = current(row);
43
+ if (invalid) { settle(row, invalid); continue; }
44
+ if (row.session === session) requests.push({ ...structuredClone(row.view), remainingMs: Math.max(0, row.deadline - now()) });
45
+ }
46
+ return { requests };
47
+ },
48
+ answer(session, id, outcome) {
49
+ if (typeof id !== 'string' || !['allow', 'deny'].includes(outcome)) throw new Error('Invalid approval response');
50
+ const row = pending.get(id);
51
+ if (!row || row.session !== session) throw new Error('Approval expired or is no longer pending');
52
+ const invalid = current(row);
53
+ if (invalid) { settle(row, invalid); throw new Error('Approval expired or invalidated'); }
54
+ // Claim synchronously before any asynchronous verification. A second tab
55
+ // or response never grants this request a second time.
56
+ settle(row, { kind: outcome, code: outcome === 'allow' ? 'HUMAN_ALLOWED' : 'HUMAN_REJECTED', deadline: row.deadline });
57
+ return { accepted: true };
58
+ },
59
+ invalidate() { for (const row of pending.values()) { const invalid = current(row); if (invalid) settle(row, invalid); } },
60
+ cancelAll() { for (const row of pending.values()) settle(row, { kind: 'cancel', code: 'HUMAN_APPROVAL_CANCELLED' }); },
61
+ dispose() { closed = true; this.cancelAll(); },
62
+ beforeDeadline(decision) { return typeof decision.deadline === 'number' && now() < decision.deadline; },
63
+ };
64
+ }