@klarkxy/dsh-safe-auto 0.2.0-rc.3 → 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.
- package/README.md +25 -8
- package/README.zh-CN.md +25 -8
- package/docs/ADR-0006.md +12 -4
- package/dsh.plugin.json +1 -1
- package/lib/client.js +420 -49
- package/package.json +12 -8
- package/src/approval-review.js +6 -2
- package/src/control.js +3 -3
- package/src/credential-paths.js +29 -0
- package/src/credential-reads.js +227 -0
- package/src/credential-text.js +361 -0
- package/src/execution-evidence.js +3 -2
- package/src/host-runtime.js +159 -0
- package/src/index.js +24 -14
- package/src/policy.js +24 -8
- package/src/review-context.js +4 -0
- package/src/review-paths.js +53 -0
- package/src/shell-command.js +48 -1
- package/src/ui-host.js +19 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@klarkxy/dsh-safe-auto",
|
|
3
|
-
"version": "0.2.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Safe Auto: an independent reviewer vets each native sandbox escalation; only a pass auto-approves that one call.",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"type": "module",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"build": "node --check src/config.js && node --check src/approval-review.js && node --check src/policy.js && node --check src/shell-command.js && node --check src/model-route.js && node --check src/review-context.js && node --check src/execution-evidence.js && node --check src/human-approval.js && node --check src/reviewer.js && node --check src/gate.js && node --check src/index.js && node --check src/control.js && node --check src/ui-host.js && tsdown && node ../../scripts/editor-plugins/wrap-client.mjs @klarkxy/dsh-safe-auto",
|
|
27
|
+
"build": "node --check src/config.js && node --check src/approval-review.js && node --check src/policy.js && node --check src/credential-paths.js && node --check src/credential-text.js && node --check src/credential-reads.js && node --check src/host-runtime.js && node --check src/review-paths.js && node --check src/shell-command.js && node --check src/model-route.js && node --check src/review-context.js && node --check src/execution-evidence.js && node --check src/human-approval.js && node --check src/reviewer.js && node --check src/gate.js && node --check src/index.js && node --check src/control.js && node --check src/ui-host.js && tsdown && node ../../scripts/editor-plugins/wrap-client.mjs @klarkxy/dsh-safe-auto",
|
|
28
28
|
"test": "node --test test/*.test.js"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
@@ -59,13 +59,16 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@klarkxy/dsh-plugin-kit": "0.
|
|
62
|
+
"@klarkxy/dsh-plugin-kit": "0.3.0",
|
|
63
|
+
"jsonc-parser": "^3.3.1",
|
|
64
|
+
"toml-eslint-parser": "^1.0.3",
|
|
63
65
|
"unbash": "^5.0.0",
|
|
66
|
+
"yaml": "^2.9.1",
|
|
64
67
|
"zod": "^4.1.5"
|
|
65
68
|
},
|
|
66
69
|
"peerDependencies": {
|
|
67
|
-
"@deepseek-ai/
|
|
68
|
-
"@deepseek-ai/
|
|
70
|
+
"@deepseek-ai/cordis": ">=4.0.4",
|
|
71
|
+
"@deepseek-ai/dsh-storage-domain": ">=0.2.0-rc.2"
|
|
69
72
|
},
|
|
70
73
|
"peerDependenciesMeta": {
|
|
71
74
|
"@deepseek-ai/dsh-storage-domain": {
|
|
@@ -73,16 +76,17 @@
|
|
|
73
76
|
}
|
|
74
77
|
},
|
|
75
78
|
"devDependencies": {
|
|
79
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
76
80
|
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
|
|
77
81
|
"@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2",
|
|
78
|
-
"@klarkxy/dsh-model-route": "0.
|
|
79
|
-
"
|
|
82
|
+
"@klarkxy/dsh-model-route": "0.2.0",
|
|
83
|
+
"jsdom": "29.1.1"
|
|
80
84
|
},
|
|
81
85
|
"publishConfig": {
|
|
82
86
|
"access": "public",
|
|
83
87
|
"registry": "https://registry.npmjs.org/"
|
|
84
88
|
},
|
|
85
89
|
"dshRelease": {
|
|
86
|
-
"contentHash": "
|
|
90
|
+
"contentHash": "82e8f79730f15a39b180ac572531df3bef696467aa79ef7bcc76727252b3d09e"
|
|
87
91
|
}
|
|
88
92
|
}
|
package/src/approval-review.js
CHANGED
|
@@ -41,7 +41,9 @@ export function assessApproval(call, config) {
|
|
|
41
41
|
(Object.hasOwn(args, 'replace_all') && typeof args.replace_all !== 'boolean')) return verdict('ask', 'INVALID_FILE_ARGUMENTS');
|
|
42
42
|
} else {
|
|
43
43
|
if (!nonempty(args.command) || !nonempty(args.description)) return verdict('ask', 'INVALID_COMMAND_ARGUMENTS');
|
|
44
|
-
|
|
44
|
+
// Native shell tools accept positive finite values; the executor owns caps.
|
|
45
|
+
// This process timeout is independent of the review budget and grant lease.
|
|
46
|
+
if (Object.hasOwn(args, 'timeoutMs') && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) return verdict('ask', 'INVALID_TIMEOUT');
|
|
45
47
|
if (Object.hasOwn(args, 'workdir') && !nonempty(args.workdir)) return verdict('ask', 'INVALID_WORKDIR');
|
|
46
48
|
if (Object.hasOwn(args, 'run_in_background') && typeof args.run_in_background !== 'boolean') return verdict('ask', 'INVALID_BACKGROUND');
|
|
47
49
|
}
|
|
@@ -55,6 +57,8 @@ export function assessApproval(call, config) {
|
|
|
55
57
|
if (containsSecret(serialized)) return verdict('deny', 'SENSITIVE_ARGUMENTS');
|
|
56
58
|
return verdict('review', 'NATIVE_APPROVAL_REVIEW', { action: {
|
|
57
59
|
tool: call.tool, arguments: JSON.parse(serialized), cwd: call.cwd,
|
|
60
|
+
...(call.reviewPaths === undefined ? {} : { reviewPaths: call.reviewPaths }),
|
|
61
|
+
...(call.trustedRuntime === undefined ? {} : { trustedRuntime: call.trustedRuntime }),
|
|
58
62
|
permission: { from: call.sandbox.mode, to: args.sandbox_permissions, scope: 'this-call-only' },
|
|
59
63
|
} });
|
|
60
64
|
}
|
|
@@ -67,7 +71,7 @@ export function escalationReason(call) {
|
|
|
67
71
|
/** Include execution identity in the pending decision; authorization is never cached across calls. */
|
|
68
72
|
export function bindingOf(call, assessment) {
|
|
69
73
|
return createHash('sha256').update(JSON.stringify({
|
|
70
|
-
tool: call.tool, args: call.args, cwd: call.cwd, sandbox: call.sandbox,
|
|
74
|
+
tool: call.tool, args: call.args, cwd: call.cwd, sandbox: call.sandbox, reviewPaths: call.reviewPaths, trustedRuntime: call.trustedRuntime,
|
|
71
75
|
task: call.task, intent: call.intent, authorizationContext: call.authorizationContext,
|
|
72
76
|
authorityIncomplete: call.authorityIncomplete, subagent: call.subagent, nested: call.nested,
|
|
73
77
|
localExecution: call.localExecution, jobsAvailable: call.jobsAvailable, shellConfined: call.shellConfined,
|
package/src/control.js
CHANGED
|
@@ -7,7 +7,7 @@ export const SETTINGS_FIELDS = REVIEW_FIELDS;
|
|
|
7
7
|
const modes = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
8
8
|
|
|
9
9
|
/** UI writes only reviewer preferences. The enable switch and budgets remain operator-owned profile config. */
|
|
10
|
-
export function createControl(base, { table, presets, sandboxPolicy, sessions, platform = process.platform, onChange = () => {} }) {
|
|
10
|
+
export function createControl(base, { table, presets, sandboxPolicy, sessions, platform = process.platform, onChange = () => {}, onSettingsChanged = () => {} }) {
|
|
11
11
|
let settings = table.get('reviewer') ?? { revision: 0, values: {} };
|
|
12
12
|
let tail = Promise.resolve();
|
|
13
13
|
const lifetime = new AbortController();
|
|
@@ -83,8 +83,8 @@ export function createControl(base, { table, presets, sandboxPolicy, sessions, p
|
|
|
83
83
|
const next = { revision: settings.revision + 1, values: Object.fromEntries(REVIEW_FIELDS.map(k => [k, nextConfig[k] ?? ''])) };
|
|
84
84
|
await table.put('reviewer', next);
|
|
85
85
|
settings = next;
|
|
86
|
-
//
|
|
87
|
-
|
|
86
|
+
// Notify committed writes even when the caller was cancelled while saving.
|
|
87
|
+
try { onSettingsChanged(next.revision); } catch {}
|
|
88
88
|
signal?.throwIfAborted();
|
|
89
89
|
onChange();
|
|
90
90
|
return settingsView();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Path names identify dedicated stores, never entire agent/config directories.
|
|
2
|
+
// Content screening handles credentials embedded in otherwise ordinary files.
|
|
3
|
+
export function normalizedCredentialPath(path) {
|
|
4
|
+
return String(path ?? '').replaceAll('\\', '/').replace(/\/+/g, '/').toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function credentialSourcePath(path) {
|
|
8
|
+
return normalizedCredentialPath(path).replace(/:(?:\$data|:\$data)$/i, '').replace(/[. ]+$/g, '')
|
|
9
|
+
.replace(/(?:\.(?:bak|backup|old|orig|save|tmp)|\.corrupt-[a-z0-9_-]+|~)+$/g, '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isCredentialPath(path) {
|
|
13
|
+
const normalized = credentialSourcePath(path);
|
|
14
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
15
|
+
const base = parts.at(-1) ?? '';
|
|
16
|
+
if (/^(?:auth(?:entication)?|credentials?|\.credentials|tokens?|token-cache|access-tokens|refresh-tokens)\.(?:jsonc?|jsonl|ya?ml|toml|db|sqlite3?)$/.test(base)) return true;
|
|
17
|
+
if (['credentials', '.netrc', '_netrc', '.git-credentials', '.dockercfg'].includes(base)) return true;
|
|
18
|
+
if (parts.includes('.ssh') && /^(?:id_(?:rsa|dsa|ecdsa|ed25519)(?:_sk)?|identity)$/.test(base)) return true;
|
|
19
|
+
if (parts.includes('.gnupg') && (parts.includes('private-keys-v1.d') || /^(?:secring|secret-keys)\./.test(base))) return true;
|
|
20
|
+
if (parts.includes('.aws') && (parts.includes('sso') && parts.includes('cache') || parts.includes('cli') && parts.includes('cache'))) return true;
|
|
21
|
+
if (parts.includes('.azure') && /^(?:msal_token_cache|accesstokens)\./.test(base)) return true;
|
|
22
|
+
if (parts.includes('gcloud') && /^(?:application_default_credentials\.json|credentials\.db|access_tokens\.db)$/.test(base)) return true;
|
|
23
|
+
return /\.(?:p12|pfx)$/.test(base);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Mutation protection deliberately remains stricter than read protection.
|
|
27
|
+
export const isMutationProtectedPath = path => /(?:^|[\\/])(?:\.git|\.ssh|\.aws|\.gnupg|\.codex|\.dsh|\.claude|\.agents|\.github|\.kube|\.azure|\.config)(?:[\\/]|$)|(?:^|[\\/])(?:\.env(?:\.[^\\/]*)?|\.npmrc|\.netrc|\.pypirc|\.gitconfig|\.bashrc|\.zshrc|\.profile|AGENTS(?:\.local)?\.md|CLAUDE\.md|id_rsa|id_ed25519|credentials)(?:[\\/]|$)|\.(?:pem|key|p12|pfx)$/i.test(String(path ?? ''));
|
|
28
|
+
|
|
29
|
+
export const isStructuredCredentialText = path => /\.(?:jsonc?|jsonl|ndjson|ya?ml|toml|ini|conf|config)$/.test(credentialSourcePath(path)) || /(?:^|\/)(?:\.env(?:\.[^/]*)?|\.npmrc|\.pypirc|\.gitconfig|config)$/.test(credentialSourcePath(path));
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
import { credentialSourcePath, isCredentialPath, isStructuredCredentialText, isMutationProtectedPath } from './credential-paths.js';
|
|
3
|
+
import { redactCredentialText } from './credential-text.js';
|
|
4
|
+
import { analyzeShell, isSafeStaticExposure, shellRefusals } from './shell-command.js';
|
|
5
|
+
|
|
6
|
+
const MAX_SOURCE_BYTES = 1024 * 1024;
|
|
7
|
+
const MAX_WINDOW_BYTES = 8 * MAX_SOURCE_BYTES;
|
|
8
|
+
const errorResult = code => {
|
|
9
|
+
const message = `safe-auto: ${code}. Use the native read tool for non-secret file content; credentials are withheld.`;
|
|
10
|
+
return { isError: true, error: { message, info: { name: 'SafeAutoError', code } }, content: [{ type: 'text', text: message }] };
|
|
11
|
+
};
|
|
12
|
+
const failure = () => { throw new Error('CREDENTIAL_READ_UNAVAILABLE'); };
|
|
13
|
+
const stripCR = line => line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
14
|
+
const fresh = value => ({ isError: false, value, content: [] });
|
|
15
|
+
const identity = (fs, target) => `${target.targetKey ?? ''}\0${fs.processPath(target)}`;
|
|
16
|
+
const fsAvailable = fs => fs && ['resolve', 'stat', 'readBytes', 'processPath'].every(key => typeof fs[key] === 'function');
|
|
17
|
+
const textRead = exec => exec.name === 'read' || exec.name === 'str_replace_editor' && exec.arguments?.command === 'view';
|
|
18
|
+
const cwdOf = exec => exec.name === 'str_replace_editor' ? undefined : exec.agent?.session?.header?.cwd;
|
|
19
|
+
|
|
20
|
+
async function resolveFile(fs, path, exec, cwd = cwdOf(exec)) {
|
|
21
|
+
const target = await fs.resolve(path, { cwd, signal: exec.signal });
|
|
22
|
+
if (isCredentialPath(path) || isCredentialPath(fs.processPath(target))) throw new Error('CREDENTIAL_PATH');
|
|
23
|
+
return { target, path, key: identity(fs, target), info: await fs.stat(target, exec.signal) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function unchanged(fs, saved, exec) {
|
|
27
|
+
const next = await resolveFile(fs, saved.path, exec);
|
|
28
|
+
if (saved.key !== next.key || saved.info?.version !== next.info?.version || saved.info?.type !== next.info?.type) failure();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function wholeSource(fs, saved, exec) {
|
|
32
|
+
const bytes = await fs.readBytes(saved.target, exec.signal, MAX_SOURCE_BYTES);
|
|
33
|
+
if (!(bytes instanceof Uint8Array) || bytes.length > MAX_SOURCE_BYTES) failure();
|
|
34
|
+
const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
35
|
+
if (source.includes('\0')) failure();
|
|
36
|
+
const sanitized = redactCredentialText(source, fs.processPath(saved.target));
|
|
37
|
+
await unchanged(fs, saved, exec);
|
|
38
|
+
return { source, sanitized };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Preserve the host's exact window and configured caps. A truncated native
|
|
42
|
+
// line is matched to its original prefix, never independently parsed as JSON.
|
|
43
|
+
function projectLine(raw, safe, rendered) {
|
|
44
|
+
raw = stripCR(raw); safe = stripCR(safe);
|
|
45
|
+
if (rendered === raw) return safe;
|
|
46
|
+
const suffix = /\.\.\. \(line truncated to (\d+) chars\)$/.exec(rendered);
|
|
47
|
+
const cap = suffix && Number(suffix[1]);
|
|
48
|
+
if (!cap || raw.length <= cap || rendered !== raw.slice(0, cap) + suffix[0]) failure();
|
|
49
|
+
return safe.slice(0, cap) + suffix[0];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function windowSource(fs, saved, value, exec) {
|
|
53
|
+
const name = credentialSourcePath(fs.processPath(saved.target));
|
|
54
|
+
const jsonl = /\.(?:jsonl|ndjson)$/i.test(name);
|
|
55
|
+
if (isStructuredCredentialText(name) && !jsonl || typeof fs.streamText !== 'function') failure();
|
|
56
|
+
const wanted = new Set(value.lines.map(line => line.number));
|
|
57
|
+
const selected = new Map();
|
|
58
|
+
let pending = '', lineNumber = 0, bytes = 0, inPrivateKey = false;
|
|
59
|
+
const consume = () => {
|
|
60
|
+
lineNumber++;
|
|
61
|
+
const raw = pending; pending = '';
|
|
62
|
+
if (raw.includes('\0')) failure();
|
|
63
|
+
if (!jsonl && /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(raw)) inPrivateKey = true;
|
|
64
|
+
if (wanted.has(lineNumber)) {
|
|
65
|
+
bytes += Buffer.byteLength(raw);
|
|
66
|
+
if (bytes > MAX_WINDOW_BYTES) failure();
|
|
67
|
+
const safe = inPrivateKey ? raw.replace(/[^\s]/g, '*') : redactCredentialText(raw, jsonl ? 'session.jsonl' : name).text;
|
|
68
|
+
selected.set(lineNumber, { raw, safe });
|
|
69
|
+
}
|
|
70
|
+
if (!jsonl && /-----END [A-Z ]*PRIVATE KEY-----/.test(raw)) inPrivateKey = false;
|
|
71
|
+
};
|
|
72
|
+
for await (const chunk of await fs.streamText(saved.target, exec.signal)) {
|
|
73
|
+
exec.signal.throwIfAborted();
|
|
74
|
+
if (typeof chunk !== 'string') failure();
|
|
75
|
+
const pieces = chunk.split('\n');
|
|
76
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
77
|
+
pending += pieces[i];
|
|
78
|
+
// Complete JSONL lines are bounded independently; the overall session
|
|
79
|
+
// may be arbitrarily large. A too-long selected line is withheld.
|
|
80
|
+
if (pending.length > MAX_SOURCE_BYTES) {
|
|
81
|
+
if (!jsonl || wanted.has(lineNumber + 1)) failure();
|
|
82
|
+
pending = pending.slice(0, MAX_SOURCE_BYTES);
|
|
83
|
+
}
|
|
84
|
+
if (i < pieces.length - 1) consume();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (pending.length) consume();
|
|
88
|
+
if (lineNumber !== value.totalLines) failure();
|
|
89
|
+
await unchanged(fs, saved, exec);
|
|
90
|
+
return selected;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function projectRead(fs, saved, result, exec) {
|
|
94
|
+
const value = result.value;
|
|
95
|
+
if (!value || !Array.isArray(value.lines) || typeof value.path !== 'string' || !Number.isInteger(value.totalLines)) failure();
|
|
96
|
+
let selected;
|
|
97
|
+
if (saved.info.size === undefined || saved.info.size > MAX_SOURCE_BYTES) selected = await windowSource(fs, saved, value, exec);
|
|
98
|
+
else {
|
|
99
|
+
const { source, sanitized } = await wholeSource(fs, saved, exec);
|
|
100
|
+
const raw = source.split('\n'), safe = sanitized.text.split('\n');
|
|
101
|
+
const total = source ? raw.length - Number(source.endsWith('\n')) : 0;
|
|
102
|
+
if (total !== value.totalLines) failure();
|
|
103
|
+
selected = new Map(value.lines.map(({ number }) => [number, { raw: raw[number - 1], safe: safe[number - 1] }]));
|
|
104
|
+
}
|
|
105
|
+
const lines = value.lines.map(({ number, text }) => {
|
|
106
|
+
const line = selected.get(number);
|
|
107
|
+
if (!line || typeof line.raw !== 'string' || typeof text !== 'string') failure();
|
|
108
|
+
return { number, text: projectLine(line.raw, line.safe, text) };
|
|
109
|
+
});
|
|
110
|
+
return fresh({ path: value.path, offset: value.offset, lines, totalLines: value.totalLines });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function projectGrep(fs, result, exec) {
|
|
114
|
+
if (!Array.isArray(result.value?.matches)) failure();
|
|
115
|
+
const sources = new Map();
|
|
116
|
+
const matches = [];
|
|
117
|
+
for (const match of result.value.matches) {
|
|
118
|
+
exec.signal.throwIfAborted();
|
|
119
|
+
if (typeof match.path !== 'string' || !Number.isInteger(match.lineNumber) || typeof match.line !== 'string') failure();
|
|
120
|
+
let lines = sources.get(match.path);
|
|
121
|
+
if (!lines) {
|
|
122
|
+
try {
|
|
123
|
+
const saved = await resolveFile(fs, match.path, exec);
|
|
124
|
+
if (saved.info?.type !== 'file' || saved.info.size > MAX_SOURCE_BYTES || sources.size >= 64) failure();
|
|
125
|
+
const { source, sanitized } = await wholeSource(fs, saved, exec);
|
|
126
|
+
lines = { raw: source.split('\n'), safe: sanitized.text.split('\n') };
|
|
127
|
+
} catch { lines = { withheld: true }; }
|
|
128
|
+
sources.set(match.path, lines);
|
|
129
|
+
}
|
|
130
|
+
const raw = lines.raw?.[match.lineNumber - 1];
|
|
131
|
+
const coherent = typeof raw === 'string' && stripCR(raw) === match.line;
|
|
132
|
+
matches.push({ path: match.path, lineNumber: match.lineNumber,
|
|
133
|
+
line: !lines.withheld && coherent ? stripCR(lines.safe[match.lineNumber - 1]) : '[content withheld; use native read]' });
|
|
134
|
+
}
|
|
135
|
+
return fresh({ matches });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Native tool registrations and their filesystem provider are trusted host
|
|
139
|
+
* components. Bind global definitions so scope shadows/replacements cannot
|
|
140
|
+
* inherit the verified 0.2.0-rc.2 read/grep pipeline contract.
|
|
141
|
+
* Read has no body-owned raw persistence; grep spills only in post-execute.
|
|
142
|
+
* Shell jobs persist before around-return, so known secret inputs are refused
|
|
143
|
+
* BEFORE their body. This is not an OS sandbox for arbitrary scripts.
|
|
144
|
+
*/
|
|
145
|
+
export function createCredentialReads(ctx, lifetimeSignal) {
|
|
146
|
+
const owners = new Map();
|
|
147
|
+
const owner = exec => {
|
|
148
|
+
const current = ctx.tools?.get?.(exec.name);
|
|
149
|
+
if (!current) return false;
|
|
150
|
+
if (!owners.has(exec.name)) owners.set(exec.name, current);
|
|
151
|
+
return owners.get(exec.name) === current && ctx.tools.get(exec.name, exec.agent) === current;
|
|
152
|
+
};
|
|
153
|
+
async function shellCheck(fs, exec) {
|
|
154
|
+
const command = exec.arguments?.command;
|
|
155
|
+
if (typeof command !== 'string') return;
|
|
156
|
+
if (isSafeStaticExposure(analyzeShell(command))) return;
|
|
157
|
+
const values = new Set([...shellRefusals(command).values,
|
|
158
|
+
...(command.match(/"[^"\r\n]+"|'[^'\r\n]+'|[^\s;&|<>]+/g) ?? []).map(value => value.replace(/^["']|["']$/g, ''))]);
|
|
159
|
+
let count = 0;
|
|
160
|
+
for (const path of values) {
|
|
161
|
+
if (isCredentialPath(path)) throw new Error('CREDENTIAL_PATH');
|
|
162
|
+
if (!isAbsolute(path) && !/^[A-Za-z]:[\\/]/.test(path)) continue;
|
|
163
|
+
if (++count > 32) throw new Error('CREDENTIAL_READ_UNAVAILABLE');
|
|
164
|
+
// Persistent shells may have a different cwd; only absolute operands
|
|
165
|
+
// have a provable target here. Relative/opaque commands stay reviewed.
|
|
166
|
+
let saved;
|
|
167
|
+
try { saved = await resolveFile(fs, path, exec); }
|
|
168
|
+
catch (error) { if (error.message === 'CREDENTIAL_PATH') throw error; continue; }
|
|
169
|
+
if (saved.info?.type !== 'file') continue;
|
|
170
|
+
try {
|
|
171
|
+
const { sanitized } = await wholeSource(fs, saved, exec);
|
|
172
|
+
if (sanitized.redacted) throw new Error('USE_SAFE_READ');
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error.message === 'USE_SAFE_READ') throw error;
|
|
175
|
+
if (isStructuredCredentialText(path)) throw new Error('USE_SAFE_READ');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { async execute(exec, next) {
|
|
180
|
+
if (lifetimeSignal) exec = { ...exec, signal: AbortSignal.any([exec.signal, lifetimeSignal]) };
|
|
181
|
+
const fs = typeof ctx.get === 'function' ? ctx.get('fs') : ctx.fs;
|
|
182
|
+
// Hosts without the FS seam still retain the lexical hard-risk policy;
|
|
183
|
+
// no filesystem projection or extra exposure is claimed for custom tools.
|
|
184
|
+
if (!fsAvailable(fs)) return next();
|
|
185
|
+
const fileTool = textRead(exec) || ['read_image', 'write', 'edit', 'str_replace_editor'].includes(exec.name);
|
|
186
|
+
const grep = exec.name === 'grep';
|
|
187
|
+
const shell = ['bash', 'pwsh', 'shell'].includes(exec.name);
|
|
188
|
+
if (!fileTool && !grep && !shell) return next();
|
|
189
|
+
try {
|
|
190
|
+
let saved;
|
|
191
|
+
const path = exec.arguments?.file_path ?? exec.arguments?.path;
|
|
192
|
+
if (fileTool && typeof path === 'string') {
|
|
193
|
+
const cwd = ['write', 'edit'].includes(exec.name)
|
|
194
|
+
? ctx.sandboxPolicy?.resolve({ session: exec.agent?.session })?.workspaceRoot ?? cwdOf(exec)
|
|
195
|
+
: cwdOf(exec);
|
|
196
|
+
saved = await resolveFile(fs, path, exec, cwd);
|
|
197
|
+
}
|
|
198
|
+
if (saved && !textRead(exec) && exec.name !== 'read_image' && isMutationProtectedPath(fs.processPath(saved.target))) throw new Error('CREDENTIAL_PATH');
|
|
199
|
+
if (grep && typeof path === 'string') await resolveFile(fs, path, exec);
|
|
200
|
+
if (shell) await shellCheck(fs, exec);
|
|
201
|
+
if (textRead(exec) && saved?.info?.type === 'file' || grep) {
|
|
202
|
+
if (!owner(exec)) throw new Error('CREDENTIAL_READER_CHANGED');
|
|
203
|
+
}
|
|
204
|
+
if (textRead(exec) && exec.name === 'str_replace_editor' && saved?.info?.type === 'file') {
|
|
205
|
+
const { sanitized } = await wholeSource(fs, saved, exec);
|
|
206
|
+
if (sanitized.redacted) throw new Error('USE_SAFE_READ');
|
|
207
|
+
}
|
|
208
|
+
exec.signal.throwIfAborted();
|
|
209
|
+
const result = await next();
|
|
210
|
+
if (result?.isError) return result;
|
|
211
|
+
exec.signal.throwIfAborted();
|
|
212
|
+
if (exec.name === 'read' && saved?.info?.type === 'file') {
|
|
213
|
+
if (!owner(exec)) failure();
|
|
214
|
+
return await projectRead(fs, saved, result, exec);
|
|
215
|
+
}
|
|
216
|
+
if (grep) {
|
|
217
|
+
if (!owner(exec)) failure();
|
|
218
|
+
return await projectGrep(fs, result, exec);
|
|
219
|
+
}
|
|
220
|
+
if (exec.name === 'str_replace_editor' && textRead(exec) && saved) await unchanged(fs, saved, exec);
|
|
221
|
+
return result;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (exec.signal.aborted) return errorResult('CANCELLED');
|
|
224
|
+
return errorResult(['CREDENTIAL_PATH', 'USE_SAFE_READ', 'CREDENTIAL_READER_CHANGED'].includes(error.message) ? error.message : 'CREDENTIAL_READ_UNAVAILABLE');
|
|
225
|
+
}
|
|
226
|
+
} };
|
|
227
|
+
}
|