dsh-perm-gate 5.1.2 → 5.3.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/CHANGELOG.ja.md +54 -1
- package/CHANGELOG.ko.md +54 -1
- package/CHANGELOG.md +134 -4
- package/README.ja.md +43 -0
- package/README.ko.md +43 -1
- package/README.md +73 -0
- package/README.zh.md +41 -0
- package/THIRD_PARTY_NOTICES.md +44 -0
- package/cordis.patch.yml +16 -1
- package/dsh.plugin.json +1 -1
- package/lib/ask-context.d.ts +49 -1
- package/lib/ask-context.js +89 -5
- package/lib/client.js +714 -86
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +243 -83
- package/lib/config.js +84 -41
- package/lib/credential-paths.d.ts +35 -0
- package/lib/credential-paths.js +119 -0
- package/lib/engine.d.ts +4 -6
- package/lib/engine.js +53 -42
- package/lib/evaluate.d.ts +20 -0
- package/lib/evaluate.js +41 -13
- package/lib/feedback.d.ts +42 -0
- package/lib/feedback.js +85 -0
- package/lib/index.d.ts +2 -2
- package/lib/index.js +143 -17
- package/lib/irreversible.js +109 -1
- package/lib/path.d.ts +31 -0
- package/lib/path.js +35 -0
- package/lib/preset-rules.d.ts +70 -0
- package/lib/preset-rules.js +388 -0
- package/lib/preset.d.ts +8 -5
- package/lib/preset.js +15 -6
- package/lib/risk.d.ts +29 -1
- package/lib/risk.js +36 -2
- package/lib/runtime.d.ts +164 -0
- package/lib/runtime.js +429 -55
- package/lib/scan-text.d.ts +69 -0
- package/lib/scan-text.js +162 -0
- package/lib/script-review.d.ts +50 -0
- package/lib/script-review.js +202 -0
- package/lib/shell.d.ts +18 -0
- package/lib/shell.js +103 -1
- package/lib/unattended.d.ts +61 -0
- package/lib/unattended.js +77 -0
- package/package.json +4 -3
- package/scripts/patch-permission-glyph.mjs +27 -13
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-declared credential paths — the long tail the built-in list cannot know.
|
|
3
|
+
*
|
|
4
|
+
* The built-in credential set (`.ssh`, `.aws`, `.npmrc`, `credentials.yaml`, …) ships in
|
|
5
|
+
* `path.ts` because the layer that needs it is P0: a `read`/`grep` call is auto-allowed
|
|
6
|
+
* one stage before the rule chain runs, and mutating a file tool never reaches a model
|
|
7
|
+
* that could be talked out of it. That is also why this cannot simply be "add a rule to
|
|
8
|
+
* `rules.yml`" for the read side — see the note in `runtime.ts` about why the auto-allow
|
|
9
|
+
* does not consult the ruleset (preset entries and hand-written entries live in one
|
|
10
|
+
* document with no provenance, so `.dsh/**` would start blocking legitimate memory
|
|
11
|
+
* reads).
|
|
12
|
+
*
|
|
13
|
+
* So the *list* is what a user extends, through the `credentialPaths` setting, and the
|
|
14
|
+
* *tier* stays where it has to be: a listed path gets the same treatment as the
|
|
15
|
+
* built-ins — reading it outside the workspace is refused, writing it is refused.
|
|
16
|
+
*
|
|
17
|
+
* Matching is pure string work: no filesystem access, no resolution, no execution. A
|
|
18
|
+
* pattern that cannot be understood is dropped rather than guessed at — an unreadable
|
|
19
|
+
* entry must not silently become "protects everything".
|
|
20
|
+
*/
|
|
21
|
+
/** Normalized, lowercase-able comparison form for a path or pattern. */
|
|
22
|
+
function norm(p, caseInsensitive) {
|
|
23
|
+
const slashed = p.trim().replace(/[\\/]+/g, '/');
|
|
24
|
+
const stripped = slashed.length > 1 ? slashed.replace(/\/+$/, '') : slashed;
|
|
25
|
+
return caseInsensitive ? stripped.toLowerCase() : stripped;
|
|
26
|
+
}
|
|
27
|
+
/** Escape regex metacharacters that carry no meaning in a path glob. */
|
|
28
|
+
function escapeRe(s) {
|
|
29
|
+
return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Translate a path glob into a regex source.
|
|
33
|
+
*
|
|
34
|
+
* `**` is translated through a placeholder so it can be restored *after* the
|
|
35
|
+
* single-`*` rule ran — replacing them in the naive order turns `**` into `[^/]*[^/]*`,
|
|
36
|
+
* which silently stops crossing directories, i.e. `secrets/**` would match nothing.
|
|
37
|
+
*/
|
|
38
|
+
function globSource(glob) {
|
|
39
|
+
const CROSS = '\u0000';
|
|
40
|
+
return escapeRe(glob)
|
|
41
|
+
.replace(/\*\*/g, CROSS)
|
|
42
|
+
.replace(/\*/g, '[^/]*')
|
|
43
|
+
.replace(/\?/g, '[^/]')
|
|
44
|
+
.split(CROSS)
|
|
45
|
+
.join('.*');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compile one entry into a matcher over an already-normalized path.
|
|
49
|
+
*
|
|
50
|
+
* - contains `*` or `?` → glob: a double star crosses directories, a single star and
|
|
51
|
+
* a question mark stay inside one. A pattern that is not absolute (no leading slash,
|
|
52
|
+
* no drive letter) and does not already start with a crossing star is additionally
|
|
53
|
+
* allowed to match at any depth (`secrets/STAR.env` covers `a/b/secrets/x.env`),
|
|
54
|
+
* because that is what people mean when they write a relative glob.
|
|
55
|
+
* - no wildcard → path fragment: matches the path itself, or a whole leading/inner
|
|
56
|
+
* segment run (`myco/token`, `.myco_token`, `vault/prod.env`).
|
|
57
|
+
*/
|
|
58
|
+
function compileEntry(raw, caseInsensitive) {
|
|
59
|
+
const entry = norm(raw, caseInsensitive);
|
|
60
|
+
if (entry === '' || entry === '/' || entry === '*')
|
|
61
|
+
return undefined;
|
|
62
|
+
const anchored = entry.startsWith('/') || /^[a-z]:/i.test(entry);
|
|
63
|
+
if (/[*?]/.test(entry)) {
|
|
64
|
+
const body = globSource(entry);
|
|
65
|
+
const source = anchored ? `^${body}$` : `(?:^|.*/)${body}$`;
|
|
66
|
+
let re;
|
|
67
|
+
try {
|
|
68
|
+
re = new RegExp(source, caseInsensitive ? 'i' : '');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return (path) => re.test(path);
|
|
74
|
+
}
|
|
75
|
+
// A plain entry is a path fragment, matched on segment boundaries only: `token` must
|
|
76
|
+
// not protect nothing while `.env` silently matches `ambient.txt`.
|
|
77
|
+
const withLead = entry.startsWith('.') || entry.includes('/') ? entry : `/${entry}`;
|
|
78
|
+
return (path) => path === entry || path.endsWith(withLead) || path.includes(`${withLead}/`) || (anchored && path.startsWith(entry));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Compile the configured credential paths into one predicate.
|
|
82
|
+
*
|
|
83
|
+
* Returns a matcher that answers `false` for an empty or unusable list, so callers can
|
|
84
|
+
* pass the raw setting straight through. `home` expands a leading `~/` the way a shell
|
|
85
|
+
* would, because the setting is written in home-relative form (`~/.myco/vault.token`)
|
|
86
|
+
* while the paths being judged are absolute. **Without `home`** the `~/` is stripped and
|
|
87
|
+
* the rest matched as a path fragment — a declared secret must not silently stop
|
|
88
|
+
* protecting because one caller did not know the user directory; the widened tail match
|
|
89
|
+
* only ever denies more, never less.
|
|
90
|
+
*/
|
|
91
|
+
export function compileCredentialPaths(entries, opts = {}) {
|
|
92
|
+
const caseInsensitive = opts.caseInsensitive ?? true;
|
|
93
|
+
const home = opts.home;
|
|
94
|
+
const matchers = [];
|
|
95
|
+
for (const raw of entries ?? []) {
|
|
96
|
+
if (typeof raw !== 'string')
|
|
97
|
+
continue;
|
|
98
|
+
let text = raw.trim();
|
|
99
|
+
if (text === '')
|
|
100
|
+
continue;
|
|
101
|
+
if (text === '~' || text.startsWith('~/') || text.startsWith('~\\')) {
|
|
102
|
+
const rest = text.slice(1).replace(/^[\\/]/, '');
|
|
103
|
+
text = home !== undefined && home !== ''
|
|
104
|
+
? `${home.replace(/[\\/]+$/, '')}/${rest}`
|
|
105
|
+
// No home known: keep the tail. `~/.myco/vault.token` becomes `.myco/vault.token`,
|
|
106
|
+
// which still matches any user directory's copy of it.
|
|
107
|
+
: rest;
|
|
108
|
+
}
|
|
109
|
+
const m = compileEntry(text, caseInsensitive);
|
|
110
|
+
if (m !== undefined)
|
|
111
|
+
matchers.push(m);
|
|
112
|
+
}
|
|
113
|
+
if (matchers.length === 0)
|
|
114
|
+
return () => false;
|
|
115
|
+
return (path) => {
|
|
116
|
+
const p = norm(path, caseInsensitive);
|
|
117
|
+
return p !== '' && matchers.some((m) => m(p));
|
|
118
|
+
};
|
|
119
|
+
}
|
package/lib/engine.d.ts
CHANGED
|
@@ -3,13 +3,11 @@ export type EngineStage = 'hard-deny' | 'grant' | 'rule' | 'default' | 'ask' | '
|
|
|
3
3
|
/** The built-in read-only / internal classification, for diagnostics and docs. */
|
|
4
4
|
export declare const AUTO_ALLOW_TOOLS: ReadonlySet<string>;
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the deny-keyword layer already skips these keys. The path/command arguments
|
|
10
|
-
* that actually describe the operation are still scanned.
|
|
6
|
+
* The document-body keys the screens skip live in `scan-text.ts`, shared with the
|
|
7
|
+
* deny-keyword layer so the two cannot drift. Re-exported here because this was their
|
|
8
|
+
* published home.
|
|
11
9
|
*/
|
|
12
|
-
export
|
|
10
|
+
export { CONTENT_ARG_KEYS } from './scan-text.js';
|
|
13
11
|
/**
|
|
14
12
|
* Deterministic P0 hard-deny reason, or undefined when the call may proceed to
|
|
15
13
|
* later stages.
|
package/lib/engine.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* P4 ask official approval seam
|
|
9
9
|
* Strictly fail-closed: P0 wins over every later stage.
|
|
10
10
|
*/
|
|
11
|
-
import { isProtectedDestructiveTarget, isSensitivePath, isWithin } from './path.js';
|
|
11
|
+
import { isProtectedCredentialPath, isProtectedDestructiveTarget, isSensitivePath, isWithin } from './path.js';
|
|
12
|
+
import { compileCredentialPaths } from './credential-paths.js';
|
|
12
13
|
import { commandIrreversibleReason, irreversibleReason } from './irreversible.js';
|
|
13
14
|
import { executableVariants, normalizeCommandText } from './command-normalize.js';
|
|
14
15
|
// 工具名名单归 `tool-names.ts` 所有(不得在此处再抄一份)。P0 **不看工具名白名单**:
|
|
@@ -23,46 +24,20 @@ const NO_EXTRA_AUTO_ALLOW = new Set();
|
|
|
23
24
|
/** The built-in read-only / internal classification, for diagnostics and docs. */
|
|
24
25
|
export const AUTO_ALLOW_TOOLS = new Set([...READ_TOOLS, ...INTERNAL_TOOLS]);
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* the deny-keyword layer already skips these keys. The path/command arguments
|
|
30
|
-
* that actually describe the operation are still scanned.
|
|
27
|
+
* The document-body keys the screens skip live in `scan-text.ts`, shared with the
|
|
28
|
+
* deny-keyword layer so the two cannot drift. Re-exported here because this was their
|
|
29
|
+
* published home.
|
|
31
30
|
*/
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
'new_string', 'old_string', 'newText', 'oldText', 'file_text', 'fileText',
|
|
35
|
-
'diff', 'patch', 'replacement', 'snippet',
|
|
36
|
-
]);
|
|
37
|
-
/** The call's non-body text: the operation, not any document it carries. */
|
|
38
|
-
function operationText(args, depth = 0) {
|
|
39
|
-
if (depth > 6)
|
|
40
|
-
return '';
|
|
41
|
-
const parts = [];
|
|
42
|
-
for (const [key, value] of Object.entries(args)) {
|
|
43
|
-
if (typeof value === 'string') {
|
|
44
|
-
if (!CONTENT_ARG_KEYS.has(key))
|
|
45
|
-
parts.push(value);
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (Array.isArray(value)) {
|
|
49
|
-
for (const item of value) {
|
|
50
|
-
if (typeof item === 'string')
|
|
51
|
-
parts.push(item);
|
|
52
|
-
else if (item !== null && typeof item === 'object')
|
|
53
|
-
parts.push(operationText(item, depth + 1));
|
|
54
|
-
}
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
if (value !== null && typeof value === 'object') {
|
|
58
|
-
parts.push(operationText(value, depth + 1));
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return parts.join(' ');
|
|
62
|
-
}
|
|
31
|
+
export { CONTENT_ARG_KEYS } from './scan-text.js';
|
|
32
|
+
import { operationText } from './scan-text.js';
|
|
63
33
|
/**
|
|
64
34
|
* Credential or private-key material a call carries.
|
|
65
35
|
*
|
|
36
|
+
* Read through `scan-text.operationText`, so a document tool's prose is no longer fed
|
|
37
|
+
* to this screen at all (issue #2): refusing `exit_plan_mode` because the plan *names*
|
|
38
|
+
* a step it intends to ask about is a self-contradiction — `decide()` classifies the
|
|
39
|
+
* same call as needing no approval one layer later.
|
|
40
|
+
*
|
|
66
41
|
* Two clauses were removed on purpose, both because this predicate reads one flat
|
|
67
42
|
* string and therefore cannot tell *using* a secret from *moving* one — and P0 is
|
|
68
43
|
* the layer nobody can appeal:
|
|
@@ -78,8 +53,15 @@ function operationText(args, depth = 0) {
|
|
|
78
53
|
* What is left is the theft shape: a secret *artifact* plus a channel out, or any
|
|
79
54
|
* non-readonly verb touching one. That pairing lives in `irreversible.ts`, so both
|
|
80
55
|
* layers agree.
|
|
56
|
+
*
|
|
57
|
+
* `id_` keeps its `.pub` half out: a public key is the artifact you hand *other*
|
|
58
|
+
* people, so writing `~/.ssh/id_ed25519.pub` (registering a key, restoring a pair by
|
|
59
|
+
* hand) was refused at the one layer nobody can appeal for no protective reason. The
|
|
60
|
+
* private halves stay in, as does `config` — an ssh config's `ProxyCommand` /
|
|
61
|
+
* `LocalCommand` is code that runs on the next connection, which is an execution
|
|
62
|
+
* plant, not a preference file.
|
|
81
63
|
*/
|
|
82
|
-
const CREDENTIAL_MATERIAL = /(?:BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY|\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b|\.ssh[\\/](
|
|
64
|
+
const CREDENTIAL_MATERIAL = /(?:BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY|\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b|\.ssh[\\/]id_(?![^\\/]*\.pub(?:$|[\\/]))|\.ssh[\\/]config|credentials\.yaml)/i;
|
|
83
65
|
/** Verbs that only look. A call made of nothing else cannot have leaked anything. */
|
|
84
66
|
const READ_ONLY_HEAD = /^(?:test-path|test|ls|dir|get-childitem|get-content|get-item|cat|type|more|head|tail|stat|file|select-string|where|which|exists)$/i;
|
|
85
67
|
/** Whether every segment of this command text is a read-only look. */
|
|
@@ -92,8 +74,13 @@ function readonlyProbe(text) {
|
|
|
92
74
|
return READ_ONLY_HEAD.test(head);
|
|
93
75
|
});
|
|
94
76
|
}
|
|
95
|
-
|
|
96
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Credential or private-key material a call carries, read out of the *operation* text
|
|
79
|
+
* only — which is `scan-text`'s job, and why the key lists are a caller input: a document
|
|
80
|
+
* body is somebody's prose, not a theft in progress.
|
|
81
|
+
*/
|
|
82
|
+
function containsCredentialMaterial(ctx) {
|
|
83
|
+
const text = operationText(ctx.args, ctx.tool, ctx.scanKeys);
|
|
97
84
|
return CREDENTIAL_MATERIAL.test(text) && !readonlyProbe(text);
|
|
98
85
|
}
|
|
99
86
|
function pathArgument(args) {
|
|
@@ -154,7 +141,7 @@ function gitRemoteRewriteReason(segments) {
|
|
|
154
141
|
*/
|
|
155
142
|
export function hardDenyReason(ctx) {
|
|
156
143
|
const args = ctx.args;
|
|
157
|
-
if (containsCredentialMaterial(
|
|
144
|
+
if (containsCredentialMaterial(ctx))
|
|
158
145
|
return 'call contains credential or private-key material';
|
|
159
146
|
// **Shape, not a name whitelist** (see tool-names.ts): a call that carries a
|
|
160
147
|
// command string and is not a known non-executor is a command call whatever the
|
|
@@ -209,9 +196,33 @@ export function hardDenyReason(ctx) {
|
|
|
209
196
|
if (isProtectedDestructiveTarget(path, ctx.dshHome))
|
|
210
197
|
return `mutation targets protected path: ${path}`;
|
|
211
198
|
}
|
|
199
|
+
// Credential-store mutation via a file tool is an access backdoor: dropping an
|
|
200
|
+
// `authorized_keys` line, `git config credential.helper store`, or rewriting
|
|
201
|
+
// `.git-credentials`/`.npmrc`/cloud creds grants or leaks authority. The shell
|
|
202
|
+
// layer already denies deletion of these stores and asks on reading them, but
|
|
203
|
+
// write/edit reached past the P0 guard because it only screened system roots, so
|
|
204
|
+
// `write ~/.ssh/authorized_keys` was auto-allowed. Scoped to file-mutation tools
|
|
205
|
+
// (not bash/pwsh, whose real target is the command string, judged above), and
|
|
206
|
+
// `.env` is excluded (project-local dev config).
|
|
207
|
+
//
|
|
208
|
+
// `credentialPaths` (a setting) is consulted here and in the read branch below: the
|
|
209
|
+
// long tail of secrets — a company vault file, a deploy key nobody shipped in a
|
|
210
|
+
// preset — has to be extendable, and this is the only layer that a *read* call cannot
|
|
211
|
+
// skip past. The tier is not configurable on purpose: a declared path gets exactly
|
|
212
|
+
// what `.ssh` gets, never a downgradable `ask`.
|
|
213
|
+
const declared = compileCredentialPaths(ctx.credentialPaths, {
|
|
214
|
+
caseInsensitive: ctx.caseInsensitive ?? false,
|
|
215
|
+
// `userHome`, not `home`: the latter is the workspace root. A caller that does not
|
|
216
|
+
// supply it gets the tail-match fallback in `compileCredentialPaths`, which is
|
|
217
|
+
// stricter, never looser.
|
|
218
|
+
...(ctx.userHome === undefined ? {} : { home: ctx.userHome }),
|
|
219
|
+
});
|
|
220
|
+
if (path !== undefined && (ctx.tool === 'write' || ctx.tool === 'edit' || DESTRUCTIVE_TOOL.test(ctx.tool)) && isProtectedCredentialPath(path, declared)) {
|
|
221
|
+
return `mutation targets a credential store: ${path}`;
|
|
222
|
+
}
|
|
212
223
|
if (READ_TOOLS.has(ctx.tool) && path !== undefined) {
|
|
213
224
|
const normalized = path.startsWith('/') || /^[A-Za-z]:/.test(path) ? path : path;
|
|
214
|
-
if (!isWithin(ctx.cwd, normalized) && isSensitivePath(normalized)) {
|
|
225
|
+
if (!isWithin(ctx.cwd, normalized) && (isSensitivePath(normalized) || declared(path))) {
|
|
215
226
|
return `read of sensitive path outside workspace: ${path}`;
|
|
216
227
|
}
|
|
217
228
|
}
|
package/lib/evaluate.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type CompiledRuleEntry, type CompiledRuleset, type RuleAction } from './rule.js';
|
|
2
2
|
import { type SimpleCommand } from './shell.js';
|
|
3
|
+
import type { ScanKeyPolicy } from './scan-text.js';
|
|
3
4
|
export interface ToolCallContext {
|
|
4
5
|
readonly tool: string;
|
|
5
6
|
readonly args: Record<string, unknown>;
|
|
@@ -7,8 +8,27 @@ export interface ToolCallContext {
|
|
|
7
8
|
readonly cwd: string;
|
|
8
9
|
/** Workspace root; used by the write-path override to decide inside vs outside. */
|
|
9
10
|
readonly home?: string;
|
|
11
|
+
/**
|
|
12
|
+
* The real user directory (`os.homedir()`), used to expand a `~/` in a declared
|
|
13
|
+
* credential path. Deliberately separate from `home`, which is the workspace root —
|
|
14
|
+
* conflating them would expand `~/.myco/vault.token` into the project and protect
|
|
15
|
+
* nothing at all.
|
|
16
|
+
*/
|
|
17
|
+
readonly userHome?: string;
|
|
10
18
|
readonly dshHome?: string;
|
|
11
19
|
readonly caseInsensitive?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* User-declared credential paths (`credentialPaths` setting), raw as configured.
|
|
22
|
+
* P0 compiles them; they extend the built-in store list for the two checks a read or
|
|
23
|
+
* write call cannot route around. Absent means the built-ins alone.
|
|
24
|
+
*/
|
|
25
|
+
readonly credentialPaths?: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* Additions to the argument-key sets that decide which parameter text a protective
|
|
28
|
+
* screen may read (`extraContentArgKeys` / `extraOperationArgKeys`). Absent means the
|
|
29
|
+
* built-in sets, which is what an embedded caller or a dry run gets.
|
|
30
|
+
*/
|
|
31
|
+
readonly scanKeys?: ScanKeyPolicy;
|
|
12
32
|
/** Agent identity candidates from session context. */
|
|
13
33
|
readonly agentCandidates?: readonly string[];
|
|
14
34
|
/** Network request info (for network dimension matching). */
|
package/lib/evaluate.js
CHANGED
|
@@ -105,9 +105,14 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
|
105
105
|
// Join the FULL argv of each simple command: `c.command` alone is just the
|
|
106
106
|
// command word, so `curl https://x | sh` would read as `curl|sh` and the
|
|
107
107
|
// documented `curl|sh` pattern could never see its own subject.
|
|
108
|
-
const
|
|
108
|
+
const argvs = commands.map((c) => commandArgv(c));
|
|
109
|
+
const pipelineText = argvs.join('|');
|
|
109
110
|
const compiled = rule.argv.pipeline.map((p) => compileGlobForMatch(p));
|
|
110
|
-
|
|
111
|
+
// Match per simple command as well as over the joined pipeline: anchored
|
|
112
|
+
// prefix patterns (`git reset --hard*`) must see each segment on its own —
|
|
113
|
+
// `cd x && git reset --hard` hides the guarded segment behind the join —
|
|
114
|
+
// while cross-segment patterns (`curl|sh`) still read the joined string.
|
|
115
|
+
if (!compiled.some((g) => g.test(pipelineText) || argvs.some((s) => g.test(s))))
|
|
111
116
|
return false;
|
|
112
117
|
}
|
|
113
118
|
// network dimension: all present sub-dimensions must match.
|
|
@@ -137,8 +142,13 @@ const SHELL_WRITE_PATTERNS = [
|
|
|
137
142
|
/(?:^|\s)(?:mv|cp|install|mkdir|touch|chmod|chown)\b/i,
|
|
138
143
|
// Redirect into a file: `> f`, `>> f`, `>f`. The leading character class
|
|
139
144
|
// excludes the stderr idioms a read-only command uses (`2>&1`, `>&2`,
|
|
140
|
-
// `2>/dev/null`, `1>&2`)
|
|
141
|
-
/(
|
|
145
|
+
// `2>/dev/null`, `1>&2`) AND the two arrow forms that are pure text, not a
|
|
146
|
+
// redirect: `=>` (JS/Rust/arrow-function syntax, e.g. `node -e "(x)=>x+1"`) and
|
|
147
|
+
// `->` (prose / C member access, e.g. `Write-Host 'a -> b'`). Without `=`/`-` in
|
|
148
|
+
// the class, a read-only command merely *containing* an arrow was escalated to
|
|
149
|
+
// `ask` (GitHub issue #4). A real redirect's `>` is still preceded by a space or
|
|
150
|
+
// an ordinary word char (`echo x > f`, `ls>f`), so those keep matching.
|
|
151
|
+
/(?:^|[^0-9&=>-])>>?\s*[^\s&|]/,
|
|
142
152
|
// `tee` always writes its target; `curl`/`wget` only when they write a file.
|
|
143
153
|
/(?:^|\s)tee\b/i,
|
|
144
154
|
/(?:^|\s)(?:curl|wget)\b.*?(?:\s-[oO]\b|>)/i,
|
|
@@ -162,26 +172,33 @@ function isWriteCommand(commandText) {
|
|
|
162
172
|
* - `shell` / `pwsh` / `terminal`: read-only commands (git status, python -c
|
|
163
173
|
* fetch, curl without redirect, etc.) follow `defaultAction`; commands that
|
|
164
174
|
* contain a write/execution pattern escalate to `ask` for review.
|
|
175
|
+
*
|
|
176
|
+
* The escalation names its own cause (GitHub issue #3). Reporting these as
|
|
177
|
+
* `no rule matched; default action` tells a reader the ruleset was silent, when in fact
|
|
178
|
+
* a safety screen just refused to let the default through — and it makes the popup say
|
|
179
|
+
* "no rule covers this" about a call a rule *did* cover in spirit.
|
|
165
180
|
*/
|
|
166
181
|
function pathAndCommandDefault(ctx, pathCandidates, defaultAction) {
|
|
167
182
|
if (defaultAction !== 'allow')
|
|
168
|
-
return
|
|
183
|
+
return undefined;
|
|
169
184
|
// write/edit: path-aware (outside workspace → ask)
|
|
170
185
|
if (WRITE_TOOLS.has(ctx.tool)) {
|
|
171
186
|
const home = ctx.home;
|
|
172
187
|
if (home !== undefined && home !== '') {
|
|
173
188
|
for (const candidate of pathCandidates) {
|
|
174
|
-
if (normalizeWorkspacePath(home, candidate, ctx.caseInsensitive) === '')
|
|
175
|
-
return 'ask';
|
|
189
|
+
if (normalizeWorkspacePath(home, candidate, ctx.caseInsensitive) === '') {
|
|
190
|
+
return { action: 'ask', why: `write target outside the workspace (${candidate})` };
|
|
191
|
+
}
|
|
176
192
|
}
|
|
177
193
|
}
|
|
178
|
-
return
|
|
194
|
+
return undefined;
|
|
179
195
|
}
|
|
180
196
|
// shell/pwsh/terminal: command-content-aware (write pattern → ask)
|
|
181
197
|
const commandText = commandTextOf(ctx);
|
|
182
|
-
if (commandText !== undefined && isWriteCommand(commandText))
|
|
183
|
-
return 'ask';
|
|
184
|
-
|
|
198
|
+
if (commandText !== undefined && isWriteCommand(commandText)) {
|
|
199
|
+
return { action: 'ask', why: 'command carries a file-write or redirect pattern' };
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
185
202
|
}
|
|
186
203
|
/**
|
|
187
204
|
* Command text of a shell call: the explicit `commandText` field when set,
|
|
@@ -219,8 +236,15 @@ export function decideRules(ruleset, ctx) {
|
|
|
219
236
|
// shell/pwsh/terminal tools check the command for write patterns.
|
|
220
237
|
// Outside safety checks → ask; inside or read-only → defaultAction.
|
|
221
238
|
let action = ruleset.defaultAction;
|
|
239
|
+
// Why this layer decided, when it is the one that decided (issue #3): kept separate
|
|
240
|
+
// from `no rule matched` so an audit line cannot impersonate a ruleset gap.
|
|
241
|
+
let screenWhy;
|
|
222
242
|
if (WRITE_TOOLS.has(ctx.tool) || shellText !== undefined) {
|
|
223
|
-
|
|
243
|
+
const escalated = pathAndCommandDefault(ctx, pathCands, action);
|
|
244
|
+
if (escalated !== undefined) {
|
|
245
|
+
action = escalated.action;
|
|
246
|
+
screenWhy = escalated.why;
|
|
247
|
+
}
|
|
224
248
|
}
|
|
225
249
|
if (action === 'allow' || action === 'ask') {
|
|
226
250
|
const allow = scan(ruleset.allow, 'allow');
|
|
@@ -230,7 +254,11 @@ export function decideRules(ruleset, ctx) {
|
|
|
230
254
|
if (ask !== undefined)
|
|
231
255
|
return ask;
|
|
232
256
|
}
|
|
233
|
-
return {
|
|
257
|
+
return {
|
|
258
|
+
action,
|
|
259
|
+
reason: screenWhy === undefined ? 'no rule matched; default action' : `${screenWhy}; escalated by the default safety screen`,
|
|
260
|
+
ruleIndex: undefined,
|
|
261
|
+
};
|
|
234
262
|
}
|
|
235
263
|
function decomposeSafe(text) {
|
|
236
264
|
try {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic review opinion for a blocked tool call — no LLM.
|
|
3
|
+
*
|
|
4
|
+
* Modeled on cc-safety-net's block message (`BLOCKED + Reason + Rule + intent
|
|
5
|
+
* instruction`): when the gate refuses a call, the main LLM should get a structured
|
|
6
|
+
* note telling it *why*, *which rule/layer* fired, and *what to do instead*, so it
|
|
7
|
+
* stays on task instead of retrying a variant. perm-gate already has the raw
|
|
8
|
+
* material — every preset entry tags its reason `[deny-rule:<id>]` and states a
|
|
9
|
+
* safer alternative in prose, and the deterministic layers emit `[hard-deny]`/
|
|
10
|
+
* `no rule matched` reasons — this module folds that into one classified opinion.
|
|
11
|
+
*
|
|
12
|
+
* Kept dependency-free and pure so it can be unit-tested and reused by both the
|
|
13
|
+
* node decision path and any UI.
|
|
14
|
+
*/
|
|
15
|
+
/** The five dispositions, mirroring cc-safety-net's intent taxonomy. */
|
|
16
|
+
export type ReviewIntent = 'hard_stop' | 'use_alternative' | 'scope_down' | 'manual_only' | 'stop_and_explain';
|
|
17
|
+
/** Per-intent guidance to the agent, zh + en (the opinion is fed to the main LLM). */
|
|
18
|
+
export declare const INTENT_ADVICE: Readonly<Record<ReviewIntent, {
|
|
19
|
+
readonly zh: string;
|
|
20
|
+
readonly en: string;
|
|
21
|
+
}>>;
|
|
22
|
+
/** Extract the `[deny-rule:<id>]` tag, a gate-danger tag (YOLO / degraded review /
|
|
23
|
+
* mechanical passthrough), or P0. */
|
|
24
|
+
export declare function ruleIdOf(reason: string): string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Classify a block reason into an intent. Ordered from the most specific signal to
|
|
27
|
+
* a conservative default; the phrasing of preset-rule reasons drives the match.
|
|
28
|
+
*/
|
|
29
|
+
export declare function intentForDeny(reason: string): ReviewIntent;
|
|
30
|
+
/**
|
|
31
|
+
* The structured opinion for one blocked call. `command` must already be redacted by
|
|
32
|
+
* the caller. Returns the classified intent plus a single line suitable for the
|
|
33
|
+
* `工具审核意见: "<text>"` wrapper the listener delivers to the session.
|
|
34
|
+
*/
|
|
35
|
+
export declare function composeReviewOpinion(input: {
|
|
36
|
+
readonly command: string;
|
|
37
|
+
readonly reason: string;
|
|
38
|
+
readonly locale?: 'zh' | 'en';
|
|
39
|
+
}): {
|
|
40
|
+
readonly intent: ReviewIntent;
|
|
41
|
+
readonly text: string;
|
|
42
|
+
};
|
package/lib/feedback.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic review opinion for a blocked tool call — no LLM.
|
|
3
|
+
*
|
|
4
|
+
* Modeled on cc-safety-net's block message (`BLOCKED + Reason + Rule + intent
|
|
5
|
+
* instruction`): when the gate refuses a call, the main LLM should get a structured
|
|
6
|
+
* note telling it *why*, *which rule/layer* fired, and *what to do instead*, so it
|
|
7
|
+
* stays on task instead of retrying a variant. perm-gate already has the raw
|
|
8
|
+
* material — every preset entry tags its reason `[deny-rule:<id>]` and states a
|
|
9
|
+
* safer alternative in prose, and the deterministic layers emit `[hard-deny]`/
|
|
10
|
+
* `no rule matched` reasons — this module folds that into one classified opinion.
|
|
11
|
+
*
|
|
12
|
+
* Kept dependency-free and pure so it can be unit-tested and reused by both the
|
|
13
|
+
* node decision path and any UI.
|
|
14
|
+
*/
|
|
15
|
+
/** Per-intent guidance to the agent, zh + en (the opinion is fed to the main LLM). */
|
|
16
|
+
export const INTENT_ADVICE = {
|
|
17
|
+
hard_stop: {
|
|
18
|
+
zh: '不得重试或绕过此操作;跳过它,继续任务的其余部分。',
|
|
19
|
+
en: 'Do not retry or bypass this operation; skip it and continue the rest of the task.',
|
|
20
|
+
},
|
|
21
|
+
use_alternative: {
|
|
22
|
+
zh: '不要重试被拦的形式;按上面给出的更安全替代继续。',
|
|
23
|
+
en: 'Do not retry the blocked form; continue with the safer alternative named above.',
|
|
24
|
+
},
|
|
25
|
+
scope_down: {
|
|
26
|
+
zh: '请把目标收窄到明确必需的路径再重试;确需大范围操作则上报用户。',
|
|
27
|
+
en: 'Retry with a narrower, explicitly-needed target; escalate to the user if a broad operation is truly required.',
|
|
28
|
+
},
|
|
29
|
+
manual_only: {
|
|
30
|
+
zh: '此命令无法静态核验,请交由用户手动执行。',
|
|
31
|
+
en: 'This command cannot be verified statically; ask the user to run it manually.',
|
|
32
|
+
},
|
|
33
|
+
stop_and_explain: {
|
|
34
|
+
zh: '不要暴力尝试变体;简化或重组命令后再试,或上报此拦截。',
|
|
35
|
+
en: 'Do not brute-force variants; simplify or restate the command, or report this block.',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
/** Extract the `[deny-rule:<id>]` tag, a gate-danger tag (YOLO / degraded review /
|
|
39
|
+
* mechanical passthrough), or P0. */
|
|
40
|
+
export function ruleIdOf(reason) {
|
|
41
|
+
const tag = /\[(deny-rule:[a-z0-9._-]+|(?:yolo|rules-fallback|safety-net)-danger:[a-z0-9._-]+)\]/.exec(reason);
|
|
42
|
+
if (tag?.[1] !== undefined)
|
|
43
|
+
return tag[1];
|
|
44
|
+
if (/\[hard-deny\]|hard-deny/i.test(reason))
|
|
45
|
+
return 'hard-deny';
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Classify a block reason into an intent. Ordered from the most specific signal to
|
|
50
|
+
* a conservative default; the phrasing of preset-rule reasons drives the match.
|
|
51
|
+
*/
|
|
52
|
+
export function intentForDeny(reason) {
|
|
53
|
+
const r = reason.toLowerCase();
|
|
54
|
+
// unverifiable / opaque target, or an act that needs the user's own consent → a human runs it
|
|
55
|
+
if (/opaque|cannot be (read|verified|analyzed)|unanalyz|encoded|unread|unresolv|non-literal|dynamic|substitution-assembled|verify the variable|explicit consent|needs? (the )?user/.test(r))
|
|
56
|
+
return 'manual_only';
|
|
57
|
+
// explicit safer alternative offered → use it
|
|
58
|
+
if (/\buse\b|\bprefer\b|\bsafer\b|\binstead\b|\bstash\b|dry-?run|\bfirst\b|\bedit specific\b|narrower/.test(r))
|
|
59
|
+
return 'use_alternative';
|
|
60
|
+
// broad/target-set concerns → narrow it. A P0 `[hard-deny]` or an unattended block is
|
|
61
|
+
// never merely "too broad": those carry an irreversibility the agent may not trade
|
|
62
|
+
// away, so the scope signal must not downgrade them into a retry-with-narrower note.
|
|
63
|
+
if (/targets? (a )?(directory|many|mass|whole|entire)|broad|everything|scope|wildcard|\.\*|recurse/.test(r) && !/never|unrecoverable|irreversib|hard-deny|yolo: block|rules-fallback: block/.test(r))
|
|
64
|
+
return 'scope_down';
|
|
65
|
+
// truly irreversible / protected stores / credential / a graded risk → hard stop
|
|
66
|
+
if (/\bhard-deny\b|-danger:|irreversib|unrecoverable|cannot be (recovered|regenerated|undone)|wipe|destroy|credential|protected path|credential store|shadow|format|risk|delete shadows|force-overwrite|protected branch/.test(r))
|
|
67
|
+
return 'hard_stop';
|
|
68
|
+
return 'stop_and_explain';
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The structured opinion for one blocked call. `command` must already be redacted by
|
|
72
|
+
* the caller. Returns the classified intent plus a single line suitable for the
|
|
73
|
+
* `工具审核意见: "<text>"` wrapper the listener delivers to the session.
|
|
74
|
+
*/
|
|
75
|
+
export function composeReviewOpinion(input) {
|
|
76
|
+
const intent = intentForDeny(input.reason);
|
|
77
|
+
const rule = ruleIdOf(input.reason);
|
|
78
|
+
const advice = INTENT_ADVICE[intent];
|
|
79
|
+
const locale = input.locale ?? 'zh';
|
|
80
|
+
const cleanReason = input.reason.replace(/\[(?:deny-rule:[a-z0-9._-]+|(?:yolo|rules-fallback|safety-net)-danger:[a-z0-9._-]+|hard-deny)\]\s*/gi, '').replace(/\s*·.*$/, '').trim();
|
|
81
|
+
const text = locale === 'zh'
|
|
82
|
+
? `已拦截。依据:${rule ?? '默认策略'}。原因:${cleanReason}。处置(${intent}):${advice.zh}`
|
|
83
|
+
: `Blocked. Rule: ${rule ?? 'default policy'}. Reason: ${cleanReason}. Disposition(${intent}): ${advice.en}`;
|
|
84
|
+
return { intent, text };
|
|
85
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { Context } from '@deepseek-ai/cordis';
|
|
9
9
|
import { Config } from './config.js';
|
|
10
|
-
import { PermGateRuntime, type ToolExecutionLike } from './runtime.js';
|
|
10
|
+
import { PermGateRuntime, type PreToolDecisionLike, type ToolExecutionLike } from './runtime.js';
|
|
11
11
|
export declare const name = "dsh-perm-gate";
|
|
12
12
|
/**
|
|
13
13
|
* The `tools` service drives `tools/pre-execute`/`tools/result` (dsh-tools).
|
|
@@ -53,5 +53,5 @@ export declare function makeApprovalAnswerer(runtime: Pick<PermGateRuntime, 'set
|
|
|
53
53
|
* `riskTimeoutMs` (default 20 s), and a grader failure keeps the original ask
|
|
54
54
|
* (fail-closed).
|
|
55
55
|
*/
|
|
56
|
-
export declare function makePreExecuteListener(runtime: Pick<PermGateRuntime, 'decideExecution' | 'refineAsk' | 'beginShellExecution'> & Partial<Pick<PermGateRuntime, 'sandboxRewrite'
|
|
56
|
+
export declare function makePreExecuteListener(runtime: Pick<PermGateRuntime, 'decideExecution' | 'refineAsk' | 'beginShellExecution'> & Partial<Pick<PermGateRuntime, 'sandboxRewrite' | 'unattendedModeOf' | 'resolveYolo' | 'resolveSafetyNet'>>, feedback?: (exec: ToolExecutionLike, decision: PreToolDecisionLike) => void): (exec: ToolExecutionLike, next: () => Promise<unknown>) => Promise<unknown>;
|
|
57
57
|
export declare function apply(ctx: Context, config?: Record<string, unknown>): PermGateRuntime;
|