@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.
- package/README.md +80 -130
- package/README.zh-CN.md +82 -119
- package/docs/ADR-0001.md +1 -1
- package/docs/ADR-0002.md +1 -1
- package/docs/ADR-0003.md +1 -1
- package/docs/ADR-0004.md +33 -0
- package/docs/ADR-0005.md +31 -0
- package/docs/ADR-0006.md +58 -0
- package/dsh.plugin.json +9 -4
- package/lib/client.js +1738 -494
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +18 -10
- package/src/approval-review.js +24 -2
- package/src/config.js +12 -35
- package/src/control.js +29 -17
- 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 +115 -0
- package/src/gate.js +37 -31
- package/src/host-runtime.js +159 -0
- package/src/human-approval.js +64 -0
- package/src/index.js +175 -203
- package/src/model-route.js +10 -29
- package/src/policy.js +39 -78
- package/src/review-context.js +35 -0
- package/src/review-paths.js +53 -0
- package/src/reviewer.js +61 -120
- package/src/shell-command.js +547 -0
- package/src/ui-host.js +60 -43
- package/src/escalation.js +0 -118
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Safe Auto",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "An independent reviewer vets each sandbox escalation; only a pass auto-approves that one call."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@klarkxy/dsh-safe-auto",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Safe Auto:
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
7
7
|
"main": "src/index.js",
|
|
@@ -24,12 +24,12 @@
|
|
|
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/
|
|
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": {
|
|
31
31
|
"node": "^22.19.0 || >=24.0.0",
|
|
32
|
-
"dsh": ">=0.
|
|
32
|
+
"dsh": ">=0.2.0-rc.2"
|
|
33
33
|
},
|
|
34
34
|
"license": "SEE LICENSE IN LICENSE",
|
|
35
35
|
"repository": {
|
|
@@ -59,11 +59,16 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"
|
|
63
|
-
"
|
|
62
|
+
"@klarkxy/dsh-plugin-kit": "0.3.0",
|
|
63
|
+
"jsonc-parser": "^3.3.1",
|
|
64
|
+
"toml-eslint-parser": "^1.0.3",
|
|
65
|
+
"unbash": "^5.0.0",
|
|
66
|
+
"yaml": "^2.9.1",
|
|
67
|
+
"zod": "^4.1.5"
|
|
64
68
|
},
|
|
65
69
|
"peerDependencies": {
|
|
66
|
-
"@deepseek-ai/
|
|
70
|
+
"@deepseek-ai/cordis": ">=4.0.4",
|
|
71
|
+
"@deepseek-ai/dsh-storage-domain": ">=0.2.0-rc.2"
|
|
67
72
|
},
|
|
68
73
|
"peerDependenciesMeta": {
|
|
69
74
|
"@deepseek-ai/dsh-storage-domain": {
|
|
@@ -71,14 +76,17 @@
|
|
|
71
76
|
}
|
|
72
77
|
},
|
|
73
78
|
"devDependencies": {
|
|
74
|
-
"@deepseek-ai/
|
|
75
|
-
"@deepseek-ai/dsh-client-ui-primitives": "0.
|
|
79
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
|
|
81
|
+
"@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2",
|
|
82
|
+
"@klarkxy/dsh-model-route": "0.2.0",
|
|
83
|
+
"jsdom": "29.1.1"
|
|
76
84
|
},
|
|
77
85
|
"publishConfig": {
|
|
78
86
|
"access": "public",
|
|
79
87
|
"registry": "https://registry.npmjs.org/"
|
|
80
88
|
},
|
|
81
89
|
"dshRelease": {
|
|
82
|
-
"contentHash": "
|
|
90
|
+
"contentHash": "82e8f79730f15a39b180ac572531df3bef696467aa79ef7bcc76727252b3d09e"
|
|
83
91
|
}
|
|
84
92
|
}
|
package/src/approval-review.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { containsSecret, hardRisk } from './policy.js';
|
|
2
3
|
|
|
3
4
|
const verdict = (kind, code, extra = {}) => ({ kind, code, ...extra });
|
|
@@ -11,9 +12,10 @@ export function assessApproval(call, config) {
|
|
|
11
12
|
if (!call || !call.args || typeof call.args !== 'object' || Array.isArray(call.args)) return verdict('ask', 'INVALID_ARGUMENTS');
|
|
12
13
|
const hard = hardRisk(call);
|
|
13
14
|
if (hard) return hard;
|
|
14
|
-
if (config.
|
|
15
|
+
if (config.enabled !== true) return verdict('ask', 'SAFE_AUTO_DISABLED');
|
|
15
16
|
if (call.sandbox?.mode !== 'workspace-write') return verdict('deny', 'WORKSPACE_SANDBOX_REQUIRED');
|
|
16
17
|
if (!call.session || typeof call.session !== 'object' || call.subagent !== false || call.nested !== false) return verdict('ask', 'NO_DIRECT_USER_AUTHORITY');
|
|
18
|
+
if (call.authorityIncomplete) return verdict('ask', 'AUTHORITY_CONTEXT_INCOMPLETE');
|
|
17
19
|
if (call.localExecution !== true || call.remote === true) return verdict('ask', 'LOCAL_EXECUTION_UNVERIFIED');
|
|
18
20
|
if (!nonempty(call.cwd)) return verdict('ask', 'INVALID_CWD');
|
|
19
21
|
if (!TOOLS.has(call.tool)) return verdict('ask', 'UNSUPPORTED_TOOL');
|
|
@@ -39,7 +41,9 @@ export function assessApproval(call, config) {
|
|
|
39
41
|
(Object.hasOwn(args, 'replace_all') && typeof args.replace_all !== 'boolean')) return verdict('ask', 'INVALID_FILE_ARGUMENTS');
|
|
40
42
|
} else {
|
|
41
43
|
if (!nonempty(args.command) || !nonempty(args.description)) return verdict('ask', 'INVALID_COMMAND_ARGUMENTS');
|
|
42
|
-
|
|
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');
|
|
43
47
|
if (Object.hasOwn(args, 'workdir') && !nonempty(args.workdir)) return verdict('ask', 'INVALID_WORKDIR');
|
|
44
48
|
if (Object.hasOwn(args, 'run_in_background') && typeof args.run_in_background !== 'boolean') return verdict('ask', 'INVALID_BACKGROUND');
|
|
45
49
|
}
|
|
@@ -53,6 +57,24 @@ export function assessApproval(call, config) {
|
|
|
53
57
|
if (containsSecret(serialized)) return verdict('deny', 'SENSITIVE_ARGUMENTS');
|
|
54
58
|
return verdict('review', 'NATIVE_APPROVAL_REVIEW', { action: {
|
|
55
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 }),
|
|
56
62
|
permission: { from: call.sandbox.mode, to: args.sandbox_permissions, scope: 'this-call-only' },
|
|
57
63
|
} });
|
|
58
64
|
}
|
|
65
|
+
|
|
66
|
+
/** Both grant and request use the native helper's exact audited reason, not a prefix match. */
|
|
67
|
+
export function escalationReason(call) {
|
|
68
|
+
return `escalate sandbox to ${call.args.sandbox_permissions}: ${call.args.justification}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Include execution identity in the pending decision; authorization is never cached across calls. */
|
|
72
|
+
export function bindingOf(call, assessment) {
|
|
73
|
+
return createHash('sha256').update(JSON.stringify({
|
|
74
|
+
tool: call.tool, args: call.args, cwd: call.cwd, sandbox: call.sandbox, reviewPaths: call.reviewPaths, trustedRuntime: call.trustedRuntime,
|
|
75
|
+
task: call.task, intent: call.intent, authorizationContext: call.authorizationContext,
|
|
76
|
+
authorityIncomplete: call.authorityIncomplete, subagent: call.subagent, nested: call.nested,
|
|
77
|
+
localExecution: call.localExecution, jobsAvailable: call.jobsAvailable, shellConfined: call.shellConfined,
|
|
78
|
+
code: assessment.code,
|
|
79
|
+
})).digest('hex');
|
|
80
|
+
}
|
package/src/config.js
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
import { isAbsolute } from 'node:path';
|
|
2
|
-
import { parseEscalationCandidates } from './escalation.js';
|
|
3
1
|
import { validateModelConfig } from './model-route.js';
|
|
4
2
|
|
|
5
3
|
const defaults = Object.freeze({
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
fastCallsPerTask: 20, deepCallsPerTask: 3, sessionBudgetUnits: 100000,
|
|
13
|
-
consecutiveDenials: 3, totalDenials: 20,
|
|
4
|
+
enabled: true,
|
|
5
|
+
provider: '', model: '', reasoningEffort: '', reviewerPrompt: '',
|
|
6
|
+
timeoutMs: 30000, maxInputBytes: 32768, outputTokens: 1024,
|
|
7
|
+
humanApprovalTimeoutMs: 60000,
|
|
8
|
+
maxReviewsPerTask: 20, consecutiveDenials: 3,
|
|
9
|
+
staticReadonly: true,
|
|
14
10
|
});
|
|
15
11
|
const limits = {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
fastCallsPerTask: [1, 100], deepCallsPerTask: [0, 20],
|
|
20
|
-
sessionBudgetUnits: [1024, 10000000], consecutiveDenials: [1, 20], totalDenials: [1, 100],
|
|
12
|
+
timeoutMs: [100, 120000], maxInputBytes: [512, 32768], outputTokens: [64, 2048],
|
|
13
|
+
maxReviewsPerTask: [1, 100], consecutiveDenials: [1, 20],
|
|
14
|
+
humanApprovalTimeoutMs: [0, 300000],
|
|
21
15
|
};
|
|
22
16
|
|
|
23
17
|
/** Immutable, load-time policy. Unknown keys fail rather than silently disabling a control. */
|
|
@@ -25,32 +19,15 @@ export function parseConfig(raw = {}) {
|
|
|
25
19
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('config must be an object');
|
|
26
20
|
for (const key of Object.keys(raw)) if (!Object.hasOwn(defaults, key)) throw new Error(`unknown config field: ${key}`);
|
|
27
21
|
const c = { ...defaults, ...raw };
|
|
28
|
-
if (
|
|
29
|
-
if (typeof c.
|
|
30
|
-
for (const key of ['
|
|
22
|
+
if (typeof c.enabled !== 'boolean') throw new Error('enabled must be a boolean');
|
|
23
|
+
if (typeof c.staticReadonly !== 'boolean') throw new Error('staticReadonly must be a boolean');
|
|
24
|
+
for (const key of ['provider', 'model', 'reasoningEffort', 'reviewerPrompt']) {
|
|
31
25
|
if (typeof c[key] !== 'string' || c[key].length > 4096) throw new Error(`${key} must be a string`);
|
|
32
26
|
}
|
|
33
|
-
for (const key of ['workspaceRoots', 'shellCandidates']) {
|
|
34
|
-
if (!Array.isArray(c[key]) || c[key].length > 100 || c[key].some(x => typeof x !== 'string' || !x || x.length > 4096)) {
|
|
35
|
-
throw new Error(`${key} must contain at most 100 nonempty strings`);
|
|
36
|
-
}
|
|
37
|
-
c[key] = Object.freeze([...new Set(c[key])]);
|
|
38
|
-
}
|
|
39
|
-
if (c.workspaceRoots.some(p => !isAbsolute(p))) throw new Error('workspaceRoots must be absolute local paths');
|
|
40
27
|
for (const [key, [min, max]] of Object.entries(limits)) {
|
|
41
28
|
if (!Number.isSafeInteger(c[key]) || c[key] < min || c[key] > max) throw new Error(`invalid ${key}`);
|
|
42
29
|
}
|
|
43
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(c.apiKeyEnv)) throw new Error('invalid apiKeyEnv');
|
|
44
|
-
if (!['max_tokens', 'max_completion_tokens'].includes(c.tokenField)) throw new Error('invalid tokenField');
|
|
45
30
|
validateModelConfig(c);
|
|
46
|
-
if (c.endpoint) {
|
|
47
|
-
const u = new URL(c.endpoint);
|
|
48
|
-
if (u.username || u.password || u.hash || u.search) throw new Error('endpoint must not contain credentials, query or fragment');
|
|
49
|
-
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
|
|
50
|
-
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) throw new Error('endpoint requires HTTPS or loopback HTTP');
|
|
51
|
-
c.endpoint = u.href;
|
|
52
|
-
}
|
|
53
|
-
c.escalationCandidates = parseEscalationCandidates(c.escalationCandidates, c.workspaceRoots);
|
|
54
31
|
return Object.freeze(c);
|
|
55
32
|
}
|
|
56
33
|
|
package/src/control.js
CHANGED
|
@@ -2,18 +2,20 @@ import { isSubagentSession } from '@klarkxy/dsh-plugin-kit/contracts';
|
|
|
2
2
|
import { parseConfig } from './config.js';
|
|
3
3
|
|
|
4
4
|
export const CHANNEL = '/dsh-safe-auto';
|
|
5
|
-
export const REVIEW_FIELDS = ['
|
|
6
|
-
export const SETTINGS_FIELDS =
|
|
5
|
+
export const REVIEW_FIELDS = ['provider', 'model', 'reasoningEffort', 'reviewerPrompt'];
|
|
6
|
+
export const SETTINGS_FIELDS = REVIEW_FIELDS;
|
|
7
7
|
const modes = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
8
8
|
|
|
9
|
-
/** UI writes only reviewer preferences.
|
|
10
|
-
export function createControl(base, { table, presets, sandboxPolicy, sessions, platform = process.platform }) {
|
|
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 = () => {}, onSettingsChanged = () => {} }) {
|
|
11
11
|
let settings = table.get('reviewer') ?? { revision: 0, values: {} };
|
|
12
12
|
let tail = Promise.resolve();
|
|
13
|
+
const lifetime = new AbortController();
|
|
13
14
|
const selections = new WeakMap();
|
|
14
15
|
let generation = 0;
|
|
15
16
|
const effective = () => parseConfig({ ...base, ...settings.values });
|
|
16
17
|
function active(session) {
|
|
18
|
+
if (lifetime.signal.aborted) return false;
|
|
17
19
|
const selected = session && selections.get(session);
|
|
18
20
|
if (!selected || selected.enabled === false) return false;
|
|
19
21
|
const events = session.snapshotEvents();
|
|
@@ -32,8 +34,8 @@ export function createControl(base, { table, presets, sandboxPolicy, sessions, p
|
|
|
32
34
|
return { active: enabled, revision: `${settings.revision}:${selected?.generation ?? 0}` };
|
|
33
35
|
}
|
|
34
36
|
function config(session) {
|
|
35
|
-
if (!active(session)) return parseConfig({ ...effective(),
|
|
36
|
-
return
|
|
37
|
+
if (!active(session)) return parseConfig({ ...effective(), enabled: false });
|
|
38
|
+
return effective();
|
|
37
39
|
}
|
|
38
40
|
function workspaceOption() {
|
|
39
41
|
return presets.catalog().options.find(o => {
|
|
@@ -43,16 +45,15 @@ export function createControl(base, { table, presets, sandboxPolicy, sessions, p
|
|
|
43
45
|
}
|
|
44
46
|
function policyView() {
|
|
45
47
|
const c = effective();
|
|
46
|
-
return { available: base.
|
|
47
|
-
candidateCounts: { workspaceRoots: c.workspaceRoots.length, shellCandidates: c.shellCandidates.length, escalationCandidates: c.escalationCandidates.length },
|
|
48
|
+
return { available: base.enabled && Boolean(workspaceOption()), platform,
|
|
48
49
|
policyLimits: { readonly: true, sandbox: 'workspace-write', approval: 'ask', rootSessionsOnly: true,
|
|
49
|
-
escalationScope: 'this-call-only',
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
escalationScope: 'this-call-only', timeoutMs: c.timeoutMs, maxInputBytes: c.maxInputBytes,
|
|
51
|
+
outputTokens: c.outputTokens, maxReviewsPerTask: c.maxReviewsPerTask, consecutiveDenials: c.consecutiveDenials,
|
|
52
|
+
humanApprovalTimeoutMs: c.humanApprovalTimeoutMs } };
|
|
52
53
|
}
|
|
53
54
|
function settingsView() {
|
|
54
55
|
const c = effective();
|
|
55
|
-
return { revision: settings.revision, values:
|
|
56
|
+
return { revision: settings.revision, values: Object.fromEntries(REVIEW_FIELDS.map(k => [k, c[k] ?? ''])), ...policyView() };
|
|
56
57
|
}
|
|
57
58
|
function sessionView(session) {
|
|
58
59
|
const catalog = presets.catalog();
|
|
@@ -76,13 +77,16 @@ export function createControl(base, { table, presets, sandboxPolicy, sessions, p
|
|
|
76
77
|
if (endpoint === 'settings.get') return settingsView();
|
|
77
78
|
if (endpoint === 'settings.save') {
|
|
78
79
|
if (payload.expectedRevision !== settings.revision) throw new Error('Settings changed; reload and retry');
|
|
79
|
-
if (base.endpoint) throw new Error('HTTP reviewer settings remain in the profile');
|
|
80
80
|
const values = payload.values;
|
|
81
81
|
if (!values || typeof values !== 'object' || Array.isArray(values) || Object.keys(values).some(k => !SETTINGS_FIELDS.includes(k))) throw new Error('Only reviewer settings may be edited here');
|
|
82
82
|
const nextConfig = parseConfig({ ...base, ...settings.values, ...values });
|
|
83
|
-
const next = { revision: settings.revision + 1, values:
|
|
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
|
+
// Notify committed writes even when the caller was cancelled while saving.
|
|
87
|
+
try { onSettingsChanged(next.revision); } catch {}
|
|
88
|
+
signal?.throwIfAborted();
|
|
89
|
+
onChange();
|
|
86
90
|
return settingsView();
|
|
87
91
|
}
|
|
88
92
|
if (endpoint === 'session.list') return { sessions: sessions.list()
|
|
@@ -95,10 +99,11 @@ export function createControl(base, { table, presets, sandboxPolicy, sessions, p
|
|
|
95
99
|
if (payload.expectedRevision !== sessionView(session).revision) throw new Error('Session changed; refresh the list and retry');
|
|
96
100
|
if (endpoint === 'session.disable') {
|
|
97
101
|
selections.set(session, { enabled: false, generation: ++generation });
|
|
102
|
+
onChange();
|
|
98
103
|
return sessionView(session);
|
|
99
104
|
}
|
|
100
105
|
if (payload.value === 'safe-auto') {
|
|
101
|
-
if (base.
|
|
106
|
+
if (!base.enabled) throw new Error('Safe Auto is disabled in the profile');
|
|
102
107
|
const option = workspaceOption();
|
|
103
108
|
if (!option) throw new Error('Workspace Write with native approval is unavailable');
|
|
104
109
|
presets.set(session, option.value);
|
|
@@ -110,11 +115,18 @@ export function createControl(base, { table, presets, sandboxPolicy, sessions, p
|
|
|
110
115
|
selections.set(session, { enabled: false, generation: ++generation });
|
|
111
116
|
presets.set(session, option.value);
|
|
112
117
|
}
|
|
118
|
+
onChange();
|
|
113
119
|
return sessionView(session);
|
|
114
120
|
}
|
|
115
|
-
return { state, config, settingsView,
|
|
121
|
+
return { state, config, settingsView, getSession, signal: lifetime.signal,
|
|
122
|
+
async dispose() {
|
|
123
|
+
lifetime.abort(new Error('Safe Auto control unloaded'));
|
|
124
|
+
await tail;
|
|
125
|
+
},
|
|
116
126
|
call(endpoint, payload, signal) {
|
|
117
|
-
|
|
127
|
+
if (lifetime.signal.aborted) return Promise.reject(lifetime.signal.reason);
|
|
128
|
+
const combined = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
|
|
129
|
+
const work = tail.then(() => dispatch(endpoint, payload, combined));
|
|
118
130
|
tail = work.catch(() => {});
|
|
119
131
|
return work;
|
|
120
132
|
},
|
|
@@ -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
|
+
}
|