@klarkxy/dsh-safe-auto 0.2.0-rc.3 → 0.2.1

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/src/policy.js CHANGED
@@ -1,12 +1,20 @@
1
- import { shellRefusals } from './shell-command.js';
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';
2
4
 
3
5
  const SHELL_TOOLS = new Set(['shell', 'bash', 'pwsh']);
4
- const PROTECTED = /(?:^|[\\/])(?:\.git|\.ssh|\.aws|\.gnupg|\.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;
6
+ const READ_TOOLS = new Set(['read', 'read_image', 'grep']);
5
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;
6
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;
7
9
  const verdict = (kind, code) => ({ kind, code });
8
10
  export const containsSecret = text => SECRET.test(text);
9
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
+
10
18
  /** Narrow lexer, NOT a shell parser. Only used to recognize a bare dangerous program name. */
11
19
  export function simpleCommand(command) {
12
20
  return typeof command === 'string' && command.length <= 4096 &&
@@ -19,17 +27,25 @@ export function simpleCommand(command) {
19
27
  export function hardRisk(call) {
20
28
  const args = call?.args ?? {};
21
29
  for (const path of [args.file_path, args.path]) {
22
- if (typeof path === 'string' && PROTECTED.test(path)) return verdict('deny', 'PROTECTED_PATH');
30
+ if (typeof path === 'string' && (isCredentialPath(path) || !readAccess(call) && isMutationProtectedPath(path))) {
31
+ return verdict('deny', 'PROTECTED_PATH');
32
+ }
23
33
  }
24
34
  if (SHELL_TOOLS.has(call?.tool) && typeof args.command === 'string') {
25
- if (args.command.split(/\s+/).some(p => PROTECTED.test(p)) || containsSecret(args.command)) return verdict('deny', 'SENSITIVE_COMMAND');
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);
26
39
  // Check decoded literal argv, assignment and redirect values, including
27
40
  // quote concatenation. Refusal-only decoding also survives an unrelated
28
41
  // dynamic fragment; it cannot establish safety or grant permission.
29
- const refusals = shellRefusals(args.command);
30
- for (const value of refusals.values) {
31
- if (containsSecret(value) || PROTECTED.test(value) ||
32
- value.split(/[=:]/).some(part => PROTECTED.test(part))) return verdict('deny', 'SENSITIVE_COMMAND');
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');
33
49
  }
34
50
  // Known literal roots remain refusals even when their arguments are not
35
51
  // statically provable. Unparseable commands keep the legacy bare check.
@@ -14,6 +14,10 @@ export function authority(session) {
14
14
  const messages = [];
15
15
  let bytes = 0, task, intent, incomplete = false;
16
16
  for (const [index, event] of events.entries()) {
17
+ // 约定禁例:source.kind === 'user' 不能当真人判据。此处安全依赖两道闸:
18
+ // 上方 isSubagentSession(session) 排除子线程,以及下游 assessApproval 再核授权。
19
+ // 宿主若改变事件来源语义,这道过滤会失效;若宿主把委派提示注入父会话为 user 事件,
20
+ // 此处授权面会扩大,评审模型 prompt 构建前必须复核。
17
21
  if (event?.type !== 'user/message' || event.data?.source?.kind !== 'user') continue;
18
22
  task = event.seq ?? index;
19
23
  const blocks = event.data.content;
@@ -0,0 +1,53 @@
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
+ }
@@ -426,7 +426,6 @@ const READONLY_COMMANDS = new Map(Object.entries({
426
426
  which: grammar('-a --all', {}, { min: 1 }),
427
427
  echo: grammar('-n -e -E'),
428
428
  printf: printfReadonly,
429
- printenv: grammar('-0 --null'),
430
429
  uname: grammar('-a -s -n -r -v -m -p -i -o --all --kernel-name --nodename --kernel-release --kernel-version --machine --processor --hardware-platform --operating-system', {}, { max: 0 }),
431
430
  id: grammar('-u -g -G -n -r -un -gn --user --group --groups --name --real', {}, { max: 1 }),
432
431
  whoami: grammar('', {}, { max: 0 }), groups: grammar(),
@@ -498,3 +497,51 @@ function readonlyInvocation(invocation) {
498
497
  export function isReadonly(analysis) {
499
498
  return analysis.ok && analysis.invocations.length > 0 && analysis.invocations.every(readonlyInvocation);
500
499
  }
500
+
501
+ // Read-only is an integrity property, not a privacy guarantee: cat, grep,
502
+ // Git object/config readers and ps can expose secrets without changing a file.
503
+ // Static approval only covers metadata and already-literal output. Every
504
+ // invocation in a pipeline must qualify; filters do not sanitize prior output.
505
+ const STATIC_METADATA_COMMANDS = new Set([
506
+ 'ls', 'stat', 'pwd', 'which', 'uname', 'id', 'whoami', 'groups', 'uptime',
507
+ 'df', 'du', 'free', 'true', 'false', 'basename', 'dirname', 'realpath',
508
+ 'readlink', 'tree', 'find', 'echo', 'printf',
509
+ ]);
510
+
511
+ function gitStaticExposure(args) {
512
+ if (args.length === 1 && ['--version', 'version'].includes(args[0])) return true;
513
+ let index = 0;
514
+ // gitReadonly has already validated these exact original controls.
515
+ while (args[index]?.startsWith('-')) index += args[index] === '-c' ? 2 : 1;
516
+ const sub = args[index++], rest = args.slice(index);
517
+ switch (sub) {
518
+ case 'config': return rest.length === 2 && rest[0] === '--get' && ['user.name', 'user.email'].includes(rest[1]);
519
+ case 'branch': return grammar('-a --all -r --remotes --list --show-current --no-color', {},
520
+ { max: rest.includes('--list') ? Infinity : 0 })(rest);
521
+ case 'tag': return (rest.includes('--list') || rest.includes('-l')) && grammar('-l --list -i --ignore-case --no-color')(rest);
522
+ case 'rev-parse':
523
+ case 'ls-files':
524
+ case 'ls-tree': return true;
525
+ default: return false; // includes history, object contents, remote URLs and config enumeration
526
+ }
527
+ }
528
+
529
+ function staticExposureInvocation(invocation) {
530
+ if (!readonlyInvocation(invocation)) return false;
531
+ // Even a metadata command must not inherit arbitrary input file descriptors.
532
+ // Here-strings are fully literal; output redirects were checked above.
533
+ if (invocation.redirects.some(redirect => redirect.operator === '<')) return false;
534
+ const [program, ...args] = invocation.argv;
535
+ if (STATIC_METADATA_COMMANDS.has(program)) return true;
536
+ if (program === 'git') return gitStaticExposure(args);
537
+ // date --file can include raw input in parse-error output. Timestamp and
538
+ // literal date/format forms are safe; reference only reads file metadata.
539
+ if (program === 'date') return !args.some(arg => arg === '-f' || arg === '--file' || arg.startsWith('--file='));
540
+ return false;
541
+ }
542
+
543
+ /** Privacy proof kept separate from the no-mutation proof. Unknown or
544
+ * content-reading commands fall back to review, not a hard refusal. */
545
+ export function isSafeStaticExposure(analysis) {
546
+ return analysis.ok && analysis.invocations.length > 0 && analysis.invocations.every(staticExposureInvocation);
547
+ }
package/src/ui-host.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createControl, CHANNEL, REVIEW_FIELDS } from './control.js';
2
2
  import { registerHostRpc } from '@klarkxy/dsh-plugin-kit/host-rpc';
3
+ import { registerModelSettings, notifyModelSettingsChanged } from '@klarkxy/dsh-plugin-kit/model-settings';
3
4
 
4
5
  export const name = 'dsh-safe-auto-ui-host';
5
6
  // Register on this fiber. connection.rpc.handle mounts routes on connection's
@@ -47,9 +48,26 @@ export async function apply(ctx) {
47
48
  let control;
48
49
  ctx.effect(() => async () => { await control?.dispose(); await domain.close(); });
49
50
  control = createControl(ctx.safeAutoRuntime.base, { table: domain.table('settings'), presets: ctx.permissionPresets,
50
- sandboxPolicy: ctx.sandboxPolicy, sessions: ctx.sessions, onChange: () => ctx.safeAutoRuntime.invalidateApprovals?.() });
51
+ sandboxPolicy: ctx.sandboxPolicy, sessions: ctx.sessions, onChange: () => ctx.safeAutoRuntime.invalidateApprovals?.(),
52
+ onSettingsChanged: revision => notifyModelSettingsChanged(ctx, '@klarkxy/dsh-safe-auto', revision) });
51
53
  // Validate stored settings before publishing any UI endpoint.
52
54
  control.settingsView();
55
+ ctx.effect(() => registerModelSettings(ctx, {
56
+ ns: '@klarkxy/dsh-safe-auto', title: { zh: '安全自动', en: 'Safe Auto' },
57
+ read(signal) {
58
+ signal.throwIfAborted(); control.signal.throwIfAborted();
59
+ const { revision, values } = control.settingsView();
60
+ const current = { provider: values.provider, model: values.model,
61
+ ...(values.reasoningEffort ? { reasoningEffort: values.reasoningEffort } : {}) };
62
+ return { revision, fields: [{ path: ['model'], label: { zh: '审查模型', en: 'Reviewer model' }, current,
63
+ followLabel: { zh: '当前会话模型', en: 'Current conversation model' }, allowFollow: true }] };
64
+ },
65
+ async write(path, route, expectedRevision, signal) {
66
+ if (path.length !== 1 || path[0] !== 'model') throw new Error('Unknown Safe Auto model setting');
67
+ return control.call('settings.save', { values: { provider: route.provider, model: route.model,
68
+ reasoningEffort: route.reasoningEffort || '' }, expectedRevision }, signal);
69
+ },
70
+ }));
53
71
  ctx.effect(() => ctx.safeAutoRuntime.attach(control));
54
72
  ctx.effect(() => registerControlRoute(ctx, async (endpoint, payload, signal, source) => {
55
73
  try {