@klarkxy/dsh-safe-auto 0.2.3 → 0.4.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.
@@ -1,115 +0,0 @@
1
- import { isAbsolute, relative, resolve } from 'node:path';
2
- import { createHash } from 'node:crypto';
3
- import { containsSecret, hardRisk } from './policy.js';
4
- import { redactCredentialText } from './credential-text.js';
5
-
6
- const MAX_FILES = 8;
7
- const MAX_FILE_BYTES = 6144;
8
- const MAX_BYTES = 12288;
9
- const digest = bytes => createHash('sha256').update(bytes).digest('hex');
10
-
11
- // This only locates possible evidence. It never interprets a shell, determines
12
- // permission, or constructs an executable command. Unsupported syntax stays in
13
- // the exact action for the reviewer. POSIX shell parsers would misread pwsh.
14
- function fileHints(text) {
15
- return (text.match(/"[^"\r\n]*"|'[^'\r\n]*'|[^\s;&|<>]+/g) ?? [])
16
- .map(value => value.replace(/^["']|["']$/g, ''))
17
- .filter(value => /\.(?:[cm]?js|tsx?|ps1|sh)$/i.test(value) && !/[\x00-\x1f$`*?{}]/.test(value));
18
- }
19
-
20
- /** Read bounded local evidence through the host's canonical filesystem seam.
21
- * Repository code is evidence, never authority. No subprocesses or network.
22
- */
23
- export async function collectExecutionEvidence(call, fs, signal) {
24
- const files = [], omissions = [], snapshots = [];
25
- const unavailable = () => ({ data: { files, omissions: ['FILESYSTEM_EVIDENCE_UNAVAILABLE'], coverage: 'partial' }, verify: async () => true });
26
- if (!['bash', 'pwsh'].includes(call.tool)) return { data: undefined, verify: async () => true };
27
- if (!fs || ['resolve', 'contains', 'stat', 'readBytes', 'processPath'].some(key => typeof fs[key] !== 'function')) return unavailable();
28
- const rootPath = call.sandbox?.workspaceRoot;
29
- const workdir = call.reviewPaths?.workdir ?? call.args.workdir ?? call.cwd;
30
- if (!isAbsolute(rootPath ?? '') || !isAbsolute(workdir ?? '')) return unavailable();
31
- const root = await fs.resolve(rootPath, { signal });
32
- const cwd = await fs.resolve(workdir, { signal });
33
- if (!fs.contains(root, cwd)) return { data: { files, omissions: ['WORKDIR_OUTSIDE_WORKSPACE'], coverage: 'partial' }, verify: async () => true };
34
- const canonicalRoot = fs.processPath(root), canonicalCwd = fs.processPath(cwd);
35
- let bytes = 0;
36
- const queued = new Set();
37
- const queue = [];
38
- const enqueue = (path, base = canonicalCwd) => {
39
- const absolute = resolve(base, path);
40
- if (queued.has(absolute)) return;
41
- queued.add(absolute); queue.push(absolute);
42
- };
43
- enqueue('package.json');
44
- if (canonicalCwd !== canonicalRoot) enqueue('package.json', canonicalRoot);
45
- for (const path of fileHints(call.args.command)) enqueue(path);
46
- // Manifests expose lifecycle hooks as well as script definitions. Follow only
47
- // local file references in relevant scripts; no transitive dependency audit.
48
- const scriptNames = new Set(call.args.command.match(/[A-Za-z0-9_:-]+/g) ?? []);
49
- if (scriptNames.has('install')) for (const name of ['preinstall', 'postinstall', 'prepare']) scriptNames.add(name);
50
- for (let i = 0; i < queue.length; i++) {
51
- signal.throwIfAborted();
52
- const path = queue[i];
53
- if (snapshots.length >= MAX_FILES || bytes >= MAX_BYTES) { omissions.push('EVIDENCE_LIMIT'); break; }
54
- if (hardRisk({ tool: 'read', args: { file_path: path } })) { omissions.push('PROTECTED_EVIDENCE'); continue; }
55
- try {
56
- const target = await fs.resolve(path, { signal });
57
- const canonical = fs.processPath(target);
58
- if (!fs.contains(root, target) || hardRisk({ tool: 'read', args: { file_path: canonical } })) { omissions.push('EVIDENCE_OUTSIDE_WORKSPACE_OR_PROTECTED'); continue; }
59
- const before = await fs.stat(target, signal);
60
- if (!before) { snapshots.push({ path, canonical, absent: true }); continue; }
61
- if (before.type !== 'file') { omissions.push('EVIDENCE_NOT_REGULAR_FILE'); continue; }
62
- const remaining = Math.min(MAX_FILE_BYTES, MAX_BYTES - bytes);
63
- const contentBytes = await fs.readBytes(target, signal, remaining);
64
- if (!(contentBytes instanceof Uint8Array) || contentBytes.byteLength > remaining) throw new Error('EVIDENCE_PROVIDER_UNBOUNDED');
65
- const after = await fs.stat(target, signal);
66
- if (after?.type !== 'file' || before.version !== after.version) throw new Error('EVIDENCE_CHANGED');
67
- const hash = digest(contentBytes);
68
- snapshots.push({ path, canonical, version: after.version, hash });
69
- let content;
70
- try { content = new TextDecoder('utf-8', { fatal: true }).decode(contentBytes); }
71
- catch { omissions.push('NON_TEXT_EVIDENCE_WITHHELD'); continue; }
72
- // UTF-16/NUL and other binary/control encodings must not bypass secret
73
- // screening or make the model review a different source representation.
74
- if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(content)) { omissions.push('NON_TEXT_EVIDENCE_WITHHELD'); continue; }
75
- if (containsSecret(content) || redactCredentialText(content, canonical).redacted) { omissions.push('SENSITIVE_EVIDENCE_WITHHELD'); continue; }
76
- // JSON keys can use escape sequences: screen their decoded form too.
77
- let manifest;
78
- if (path.endsWith('package.json')) {
79
- manifest = JSON.parse(content);
80
- if (containsSecret(JSON.stringify(manifest))) { omissions.push('SENSITIVE_EVIDENCE_WITHHELD'); continue; }
81
- }
82
- bytes += Buffer.byteLength(content);
83
- files.push({ path: relative(canonicalRoot, canonical), content, sha256: hash });
84
- if (path.endsWith('package.json')) {
85
- for (const [name, command] of Object.entries(manifest.scripts ?? {})) {
86
- if (!scriptNames.has(name) || typeof command !== 'string') continue;
87
- for (const hint of fileHints(command)) enqueue(hint, resolve(path, '..'));
88
- }
89
- }
90
- } catch (error) {
91
- signal.throwIfAborted();
92
- if (['EVIDENCE_CHANGED', 'EVIDENCE_PROVIDER_UNBOUNDED'].includes(error.message)) throw error;
93
- omissions.push(error.code === 'FS_TOO_LARGE' ? 'EVIDENCE_TOO_LARGE' : 'EVIDENCE_UNREADABLE');
94
- }
95
- }
96
- const data = { files, omissions: [...new Set(omissions)], coverage: 'partial' };
97
- return { data, async verify(signalForVerification = signal) {
98
- const signal = signalForVerification;
99
- signal.throwIfAborted();
100
- // Re-resolve containment and reread digests, including previously absent
101
- // manifests. Stat versions alone are not a content identity guarantee.
102
- const nextRoot = await fs.resolve(rootPath, { signal });
103
- const nextCwd = await fs.resolve(workdir, { signal });
104
- if (fs.processPath(nextRoot) !== canonicalRoot || fs.processPath(nextCwd) !== canonicalCwd || !fs.contains(nextRoot, nextCwd)) return false;
105
- for (const saved of snapshots) {
106
- const target = await fs.resolve(saved.path, { signal });
107
- if (fs.processPath(target) !== saved.canonical || !fs.contains(nextRoot, target)) return false;
108
- const info = await fs.stat(target, signal);
109
- if (saved.absent) { if (info) return false; continue; }
110
- if (info?.type !== 'file' || info.version !== saved.version) return false;
111
- if (digest(await fs.readBytes(target, signal, MAX_FILE_BYTES)) !== saved.hash) return false;
112
- }
113
- return true;
114
- } };
115
- }
package/src/gate.js DELETED
@@ -1,81 +0,0 @@
1
- import { assessApproval } from './approval-review.js';
2
- import { review } from './reviewer.js';
3
- import { resolveReviewRoute, sameRoute } from './model-route.js';
4
- const REVIEW_FAILURES = new Set(['REVIEW_TIMEOUT', 'REVIEW_CANCELLED', 'REVIEW_BUDGET_EXHAUSTED',
5
- 'REVIEW_INPUT_TOO_LARGE', 'NATIVE_REVIEWER_UNAVAILABLE', 'CONVERSATION_MODEL_UNAVAILABLE',
6
- 'MISSING_OR_SENSITIVE_AUTHORITY', 'SENSITIVE_REVIEW_CONTEXT', 'SENSITIVE_ACTION', 'SENSITIVE_REVIEWER_PROMPT']);
7
- const failureCode = error => REVIEW_FAILURES.has(error?.message) ? error.message
8
- : /^(?:INVALID_|MISSING_COMPLETION|UNEXPECTED_|INCONSISTENT_|DUPLICATE_|REVIEW_RESPONSE_TOO_LARGE)/.test(error?.message ?? '')
9
- ? 'INVALID_REVIEW_RESPONSE' : 'REVIEW_UNAVAILABLE';
10
-
11
- /** Per-instance, per-session budgets. Model changes never reset the ledger. */
12
- export function createGate(config, { audit = () => {}, getLlm = () => undefined } = {}) {
13
- const sessions = new WeakMap();
14
- const abort = new AbortController();
15
- function state(session, task) {
16
- let s = sessions.get(session);
17
- if (!s) {
18
- s = { task, reviews: 0, consecutive: 0, reportedTokens: 0 };
19
- sessions.set(session, s);
20
- }
21
- // A new direct user message resets the per-task budget and the denial streak.
22
- if (s.task !== task) {
23
- s.task = task; s.reviews = 0; s.consecutive = 0;
24
- }
25
- return s;
26
- }
27
- return {
28
- dispose() { abort.abort(); },
29
- humanAllowed(call) {
30
- const s = sessions.get(call.session);
31
- if (s?.task === call.task) s.consecutive = 0;
32
- },
33
- async decide(call, currentConfig = config) {
34
- // One ledger survives reviewer preference changes; settings edits do not refill budgets.
35
- const config = currentConfig;
36
- const start = Date.now();
37
- let result = assessApproval(call, config);
38
- let s;
39
- if (result.kind === 'review') {
40
- if (!call.session || call.subagent) result = { kind: 'ask', code: 'NO_DIRECT_USER_AUTHORITY' };
41
- else {
42
- s = state(call.session, call.task);
43
- if (s.consecutive >= config.consecutiveDenials) {
44
- result = { kind: 'ask', code: 'CIRCUIT_OPEN' };
45
- } else {
46
- const oldTask = s.task;
47
- try {
48
- const signal = AbortSignal.any([call.signal, abort.signal]);
49
- const route = resolveReviewRoute(config, call);
50
- const llm = getLlm();
51
- const verdict = await review(config, result.action, call.intent, s, signal, { route, llm,
52
- authorizationContext: call.authorizationContext, executionEvidence: call.executionEvidence });
53
- const details = { risk: verdict.risk, authorization: verdict.authorization, bounded: verdict.bounded, reason: verdict.reason };
54
- if (signal.aborted || s.task !== oldTask) result = { kind: 'deny', code: 'STALE_REVIEW' };
55
- else if (verdict.decision === 'deny') result = { kind: 'deny', code: 'MODEL_NOT_ALLOWED', ...details };
56
- else if (!sameRoute(route, resolveReviewRoute(config, call)) || llm !== getLlm()) {
57
- result = { kind: 'ask', code: 'REVIEW_MODEL_CHANGED' };
58
- } else result = { kind: verdict.decision, code: verdict.decision === 'allow' ? 'MODEL_ALLOWED' : 'REVIEW_NEEDS_CONFIRMATION', ...details,
59
- ...(verdict.decision === 'allow' ? { reviewRoute: route, reviewLlm: llm } : {}),
60
- };
61
- } catch (error) { result = { kind: 'ask', code: failureCode(error) }; }
62
- // Missing evidence or a transport failure is not a semantic denial.
63
- // Reservations remain charged, so repeated uncertainty is bounded.
64
- if (result.kind === 'deny') s.consecutive++;
65
- else s.consecutive = 0;
66
- }
67
- }
68
- }
69
- if (call.signal.aborted || abort.signal.aborted) result = { kind: 'cancel', code: 'CANCELLED' };
70
- // Audit failures cannot accidentally grant permission. Do not include arguments, intent or errors.
71
- try { audit({ phase: 'assessment', tool: call.tool, callId: call.callId, decision: result.kind, code: result.code,
72
- durationMs: Date.now() - start,
73
- // Rationale is kept in the result, not logs: a model can echo raw commands or secrets.
74
- ...(result.risk ? { risk: result.risk, authorization: result.authorization, bounded: result.bounded,
75
- reason: '[reviewer rationale withheld from audit]' } : {}),
76
- ...(s ? { reviews: s.reviews, reportedTokens: s.reportedTokens } : {}),
77
- }); } catch { result = { kind: 'deny', code: 'AUDIT_UNAVAILABLE' }; }
78
- return result;
79
- },
80
- };
81
- }
@@ -1,159 +0,0 @@
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
- }
@@ -1,64 +0,0 @@
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
- }
package/src/policy.js DELETED
@@ -1,58 +0,0 @@
1
- import { analyzeShell, isReadonly, isSafeStaticExposure, shellRefusals } from './shell-command.js';
2
- import { commandForPolicy } from './host-runtime.js';
3
- import { credentialSourcePath, isCredentialPath, isMutationProtectedPath } from './credential-paths.js';
4
-
5
- const SHELL_TOOLS = new Set(['shell', 'bash', 'pwsh']);
6
- const READ_TOOLS = new Set(['read', 'read_image', 'grep']);
7
- const DANGEROUS_PROGRAM = /^(?:sudo|su|doas|rm|rmdir|del|erase|mkfs(?:\..*)?|shutdown|reboot|curl|wget|nc|ncat|ssh|scp|sftp|powershell|pwsh|bash|sh|eval|exec|env|xargs)$/i;
8
- const SECRET = /\b(?:sk-|ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_-]{8,}|\bBearer\s+\S+|(?:password|api[_-]?key|token|secret)["']?\s*[:=]\s*\S+/i;
9
- const verdict = (kind, code) => ({ kind, code });
10
- export const containsSecret = text => SECRET.test(text);
11
-
12
- // These files mix normal settings with possible secrets. Native read can
13
- // sanitize values; raw shell content readers cannot. Directory metadata and
14
- // literal output remain usable, and package.json/generic JSON are not denied.
15
- const shellMixedConfigPath = path => /(?:^|\/)(?:\.env(?:\.[^/]*)?|\.npmrc|\.pypirc|\.gitconfig)$|(?:^|\/)\.(?:git|kube|aws)\/config$|(?:^|\/)\.docker\/config\.json$|(?:^|\/)\.(?:codex|dsh|claude|agents)\/(?:[^/]+\/)*(?:config|settings(?:\.local)?|cordis)\.(?:toml|ya?ml|jsonc?)$/.test(credentialSourcePath(path));
16
- const readAccess = call => READ_TOOLS.has(call?.tool) || call?.tool === 'str_replace_editor' && call?.args?.command === 'view';
17
-
18
- /** Narrow lexer, NOT a shell parser. Only used to recognize a bare dangerous program name. */
19
- export function simpleCommand(command) {
20
- return typeof command === 'string' && command.length <= 4096 &&
21
- /^[A-Za-z0-9_./:= -]+$/.test(command) && command === command.trim() &&
22
- !command.includes(' ') && !command.split(' ').some(x => x === '..') &&
23
- !command.split(' ')[0].includes('=') && !command.split(' ')[0].includes('/');
24
- }
25
-
26
- /** Hard refusals are evaluated before any review; a model verdict cannot soften these. */
27
- export function hardRisk(call) {
28
- const args = call?.args ?? {};
29
- for (const path of [args.file_path, args.path]) {
30
- if (typeof path === 'string' && (isCredentialPath(path) || !readAccess(call) && isMutationProtectedPath(path))) {
31
- return verdict('deny', 'PROTECTED_PATH');
32
- }
33
- }
34
- if (SHELL_TOOLS.has(call?.tool) && typeof args.command === 'string') {
35
- const screened = commandForPolicy(call);
36
- if (containsSecret(args.command)) return verdict('deny', 'SENSITIVE_COMMAND');
37
- const analysis = analyzeShell(screened), readonly = isReadonly(analysis);
38
- const safeExposure = isSafeStaticExposure(analysis);
39
- // Check decoded literal argv, assignment and redirect values, including
40
- // quote concatenation. Refusal-only decoding also survives an unrelated
41
- // dynamic fragment; it cannot establish safety or grant permission.
42
- // Only attested runner entry positions differ; all data operands remain.
43
- const refusals = shellRefusals(screened);
44
- for (const value of [...screened.split(/\s+/), ...refusals.values]) {
45
- const parts = [value, ...value.split(/[=:]/)];
46
- if (containsSecret(value) || parts.some(isCredentialPath) ||
47
- !readonly && parts.some(isMutationProtectedPath)) return verdict('deny', 'SENSITIVE_COMMAND');
48
- if (readonly && !safeExposure && parts.some(shellMixedConfigPath)) return verdict('deny', 'USE_SAFE_READ');
49
- }
50
- // Known literal roots remain refusals even when their arguments are not
51
- // statically provable. Unparseable commands keep the legacy bare check.
52
- if (refusals.dangerousRoot !== undefined ||
53
- simpleCommand(args.command) && DANGEROUS_PROGRAM.test(args.command.split(' ')[0])) {
54
- return verdict('deny', 'DANGEROUS_PROGRAM');
55
- }
56
- }
57
- return undefined;
58
- }
@@ -1,35 +0,0 @@
1
- import { isSubagentSession } from '@klarkxy/dsh-plugin-kit/contracts';
2
- import { containsSecret } from './policy.js';
3
-
4
- const MAX_AUTHORITY_BYTES = 12288;
5
-
6
- /** Keep direct-human messages in order, including later restrictions and revocations.
7
- * Never turn a summary, assistant claim or tool result into authority. No lossy
8
- * history selection: a missing constraint must not leave an older grant usable.
9
- */
10
- export function authority(session) {
11
- if (isSubagentSession(session)) return {};
12
- const events = session?.snapshotEvents?.();
13
- if (!Array.isArray(events)) return {};
14
- const messages = [];
15
- let bytes = 0, task, intent, incomplete = false;
16
- for (const [index, event] of events.entries()) {
17
- // 约定禁例:source.kind === 'user' 不能当真人判据。此处安全依赖两道闸:
18
- // 上方 isSubagentSession(session) 排除子线程,以及下游 assessApproval 再核授权。
19
- // 宿主若改变事件来源语义,这道过滤会失效;若宿主把委派提示注入父会话为 user 事件,
20
- // 此处授权面会扩大,评审模型 prompt 构建前必须复核。
21
- if (event?.type !== 'user/message' || event.data?.source?.kind !== 'user') continue;
22
- task = event.seq ?? index;
23
- const blocks = event.data.content;
24
- const valid = Array.isArray(blocks) && blocks.every(b => b?.type === 'text' && typeof b.text === 'string');
25
- intent = valid ? blocks.map(b => b.text).join('\n') : undefined;
26
- if (!valid || containsSecret(intent)) { incomplete = true; continue; }
27
- bytes += Buffer.byteLength(intent);
28
- if (bytes > MAX_AUTHORITY_BYTES) { incomplete = true; continue; }
29
- messages.push({ seq: task, text: intent });
30
- }
31
- if (task === undefined) return {};
32
- if (incomplete) return { task, authorityIncomplete: true };
33
- return { task, intent,
34
- ...(messages.length > 1 ? { authorizationContext: { directUserMessages: messages, complete: true } } : {}) };
35
- }
@@ -1,53 +0,0 @@
1
- import path from 'node:path';
2
-
3
- const shellTools = new Set(['bash', 'pwsh']);
4
- const fileTools = new Set(['write', 'edit']);
5
- const pathText = value => typeof value === 'string' && Boolean(value.trim()) && !/[\x00-\x1f\x7f]/.test(value);
6
-
7
- /**
8
- * Resolve only the coordinates used for review, never execution arguments.
9
- * These are lexical absolute paths, NOT realpath/canonical filesystem identities.
10
- * Evidence collection must still use fs.resolve/processPath/contains before reads.
11
- *
12
- * Native tool-pwsh resolves relative workdir against header.cwd; tool-bash uses
13
- * sandboxPolicy.workspaceRoot first. File mutations pass that policy root as
14
- * fs.resolve's cwd. Keep these separate even when a normal session makes them equal.
15
- * processPathFromHostPath is an optional, synchronous host-world identity check;
16
- * it cannot establish locality when a provider translates or withholds a path.
17
- */
18
- export function resolveReviewPaths(call, fs, pathImpl = path) {
19
- const unavailable = () => ({ localExecution: false });
20
- try {
21
- if (typeof fs?.processPathFromHostPath !== 'function') return unavailable();
22
- const fullyAbsolute = value => pathText(value) && pathImpl.isAbsolute(value) &&
23
- (pathImpl.sep !== '\\' || /^[A-Za-z]:[\\/]|^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value));
24
- const normalizeAbsolute = value => fullyAbsolute(value) ? pathImpl.normalize(value) : undefined;
25
- const cwd = normalizeAbsolute(call?.cwd);
26
- const root = normalizeAbsolute(call?.sandbox?.workspaceRoot ?? call?.cwd);
27
- if (!cwd || !root) return unavailable();
28
- const resolveFrom = (value, base) => {
29
- if (!pathText(value)) return undefined;
30
- if (pathImpl.isAbsolute(value)) return normalizeAbsolute(value);
31
- // Windows drive-relative paths consult ambient per-drive cwd; do not infer it.
32
- if (pathImpl.sep === '\\' && /^[A-Za-z]:/.test(value)) return undefined;
33
- return pathImpl.resolve(base, value);
34
- };
35
- let result;
36
- if (shellTools.has(call.tool)) {
37
- const base = call.tool === 'pwsh' ? cwd : root;
38
- const workdir = call.args?.workdir === undefined ? base : resolveFrom(call.args.workdir, base);
39
- if (!workdir) return unavailable();
40
- result = { workdir };
41
- } else if (fileTools.has(call.tool)) {
42
- const filePath = resolveFrom(call.args?.file_path, root);
43
- if (!filePath) return unavailable();
44
- result = { filePath };
45
- } else return unavailable();
46
- for (const value of [cwd, root, ...Object.values(result)]) {
47
- if (fs.processPathFromHostPath(value) !== value) return unavailable();
48
- }
49
- return { localExecution: true, ...result };
50
- } catch {
51
- return unavailable();
52
- }
53
- }