dsh-perm-gate 5.2.0 → 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/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 +152 -1
- package/lib/config.js +20 -1
- 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 +34 -11
- package/lib/feedback.d.ts +42 -0
- package/lib/feedback.js +85 -0
- package/lib/index.d.ts +2 -2
- package/lib/index.js +119 -17
- package/lib/irreversible.js +109 -1
- package/lib/path.d.ts +31 -0
- package/lib/path.js +35 -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 +423 -54
- 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 +1 -1
- package/scripts/patch-permission-glyph.mjs +27 -13
package/lib/runtime.js
CHANGED
|
@@ -9,18 +9,21 @@
|
|
|
9
9
|
* live harness.
|
|
10
10
|
*/
|
|
11
11
|
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
12
13
|
import { createHash } from 'node:crypto';
|
|
13
14
|
import { makeEntry, MemoryAuditMirror } from './audit.js';
|
|
14
15
|
import { resolvePermissiveStrategies } from './config.js';
|
|
15
16
|
import { appendAllowCommand, listAllowCommands, listAllowFromRulesDoc, replaceAllowCommands } from './allowlist.js';
|
|
16
|
-
import { decide
|
|
17
|
+
import { decide } from './engine.js';
|
|
18
|
+
import { keywordScanText } from './scan-text.js';
|
|
17
19
|
import { EventLog, saveEventSnapshots } from './events.js';
|
|
18
20
|
import { decideRules } from './evaluate.js';
|
|
19
21
|
import { canonicalizeCall, GrantRegistry } from './grant.js';
|
|
20
22
|
import { learnKey, operationFingerprint, RiskLearning } from './learning.js';
|
|
21
23
|
import { ArtifactRegistry } from './path.js';
|
|
22
|
-
import { classifyRisk, classifyRiskWith, RISK_SYSTEM_PROMPT_VERSION } from './risk.js';
|
|
23
|
-
import { askDoubtKinds, buildAskContext, humanBrief, humanCardLine, humanCommand, toEventFields } from './ask-context.js';
|
|
24
|
+
import { classifyRisk, classifyRiskWith, graderNote, RISK_SYSTEM_PROMPT_VERSION } from './risk.js';
|
|
25
|
+
import { askDoubtKinds, buildAskContext, humanBrief, humanCardLine, humanCommand, toEventFields, toGraderPayload } from './ask-context.js';
|
|
26
|
+
import { readScriptContents, scriptRefs, scriptsFullyCovered } from './script-review.js';
|
|
24
27
|
import { redactCommandText } from './redact.js';
|
|
25
28
|
import { executableVariants, normalizeCommandText } from './command-normalize.js';
|
|
26
29
|
import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
|
|
@@ -31,6 +34,7 @@ import { extractAgentCandidates } from './agent-identity.js';
|
|
|
31
34
|
import { resolveRuleChain } from './rule-chain.js';
|
|
32
35
|
import { DEFAULT_DENY_KEYWORDS } from './deny-defaults.js';
|
|
33
36
|
import { permissionPresetOf, presetInScope } from './preset.js';
|
|
37
|
+
import { resolveTierMode } from './unattended.js';
|
|
34
38
|
import { resolveGatePresets } from './config.js';
|
|
35
39
|
/** Locate a rule by its chain-wide index (deny entries first, then allow, then ask). */
|
|
36
40
|
function findRuleByIndex(ruleset, index) {
|
|
@@ -399,8 +403,14 @@ export class PermGateRuntime {
|
|
|
399
403
|
// `decideRules` uses this to determine whether a write/edit target is
|
|
400
404
|
// inside the workspace or outside it.
|
|
401
405
|
home: cwdOf(exec),
|
|
406
|
+
// The real user directory — distinct from `home` above, which is the workspace
|
|
407
|
+
// root. A declared credential path is written `~/.myco/vault.token`, and expanding
|
|
408
|
+
// that against the workspace would silently protect nothing.
|
|
409
|
+
userHome: homedir(),
|
|
402
410
|
caseInsensitive: this.options.caseInsensitivePaths ?? true,
|
|
403
411
|
dshHome: this.options.dshHome,
|
|
412
|
+
credentialPaths: this.liveCredentialPaths(),
|
|
413
|
+
scanKeys: this.liveScanKeys(),
|
|
404
414
|
agentCandidates: extractAgentCandidates(exec),
|
|
405
415
|
};
|
|
406
416
|
}
|
|
@@ -646,6 +656,71 @@ export class PermGateRuntime {
|
|
|
646
656
|
? this.options.readPermissive()
|
|
647
657
|
: { enabled: this.permissiveEnabled, strategies: this.strategies };
|
|
648
658
|
}
|
|
659
|
+
/** Whether YOLO mode is live (never human-confirm). */
|
|
660
|
+
isYolo() {
|
|
661
|
+
return this.options.readYolo !== undefined ? this.options.readYolo() : (this.options.yolo ?? false);
|
|
662
|
+
}
|
|
663
|
+
/** Whether degraded review is live (no reviewer ⇒ rule-only gate). */
|
|
664
|
+
isRulesFallback() {
|
|
665
|
+
return this.options.readRulesFallback !== undefined
|
|
666
|
+
? this.options.readRulesFallback()
|
|
667
|
+
: (this.options.rulesFallback ?? false);
|
|
668
|
+
}
|
|
669
|
+
/** Whether script-body review is live (default on). */
|
|
670
|
+
isScriptReview() {
|
|
671
|
+
return this.options.readScriptReview !== undefined
|
|
672
|
+
? this.options.readScriptReview()
|
|
673
|
+
: (this.options.scriptReview ?? true);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* The scripts one call will run, read for review.
|
|
677
|
+
*
|
|
678
|
+
* `bash ./clean-home.sh` names a file; the operation is inside it. So the body is
|
|
679
|
+
* fetched (bounded, never executing anything) and handed to the same three reviewers
|
|
680
|
+
* the rest of the call is: the deterministic nets judge it (an irreversible body is
|
|
681
|
+
* `I` no model may clear), the grader is shown it (`script_contents`), and the human
|
|
682
|
+
* card says it was attached. When *every* named script was read, the call is no longer
|
|
683
|
+
* an unreadable payload — that is what lifts a workspace-outside script run out of the
|
|
684
|
+
* `U` tier instead of parking it as unknowable.
|
|
685
|
+
*
|
|
686
|
+
* Best-effort by construction: any read problem yields "nothing attached", which the
|
|
687
|
+
* tier machinery already treats as opaque. It must never throw into the decision path.
|
|
688
|
+
*/
|
|
689
|
+
scriptReviewFor(exec, workspace) {
|
|
690
|
+
if (!this.isScriptReview())
|
|
691
|
+
return {};
|
|
692
|
+
try {
|
|
693
|
+
const args = (exec.arguments ?? {});
|
|
694
|
+
const text = typeof exec.commandText === 'string' && exec.commandText !== ''
|
|
695
|
+
? exec.commandText
|
|
696
|
+
: (typeof args.command === 'string' ? args.command : (typeof args.cmd === 'string' ? args.cmd : ''));
|
|
697
|
+
if (text === '')
|
|
698
|
+
return {};
|
|
699
|
+
const refs = scriptRefs(text);
|
|
700
|
+
if (refs.length === 0)
|
|
701
|
+
return {};
|
|
702
|
+
const contents = readScriptContents(refs, workspace ?? process.cwd());
|
|
703
|
+
if (contents.length === 0)
|
|
704
|
+
return {};
|
|
705
|
+
return { scriptContents: contents, scriptsCovered: scriptsFullyCovered(refs, contents) };
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
return {};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Whether a risk grader is reachable: an injected hook, the host `llm` service in
|
|
713
|
+
* host mode, or a configured custom endpoint. When false, YOLO decides purely on the
|
|
714
|
+
* deterministic danger signal (fail-open for a non-dangerous ask).
|
|
715
|
+
*/
|
|
716
|
+
classifierUsable() {
|
|
717
|
+
if (this.options.riskHook !== undefined)
|
|
718
|
+
return true;
|
|
719
|
+
if (this.liveClassifySource() === 'host' && this.options.hostLlm !== undefined)
|
|
720
|
+
return true;
|
|
721
|
+
const cfg = this.options.readClassifyConfig?.();
|
|
722
|
+
return cfg?.endpoint !== undefined && cfg?.endpoint !== '' && cfg?.model !== undefined && cfg?.model !== '';
|
|
723
|
+
}
|
|
649
724
|
/**
|
|
650
725
|
* The session's selected permission preset, folded from its event log. The
|
|
651
726
|
* fold is cached on the log's length plus the identity of its last event: a
|
|
@@ -732,7 +807,38 @@ export class PermGateRuntime {
|
|
|
732
807
|
liveDenyKeywords() {
|
|
733
808
|
const read = this.options.readDenyKeywords;
|
|
734
809
|
const override = read !== undefined ? read() : undefined;
|
|
735
|
-
|
|
810
|
+
const base = Array.isArray(override) && override.length > 0 ? override : DEFAULT_DENY_KEYWORDS;
|
|
811
|
+
// Extras append, never replace, and the result is de-duplicated: listing the same
|
|
812
|
+
// word twice should not read as two distinct protections in the audit.
|
|
813
|
+
const extraRead = this.options.readDenyKeywordsExtra;
|
|
814
|
+
const extra = extraRead !== undefined ? extraRead() : (this.options.denyKeywordsExtra ?? []);
|
|
815
|
+
if (!Array.isArray(extra) || extra.length === 0)
|
|
816
|
+
return base;
|
|
817
|
+
const appended = extra.map((k) => String(k).trim()).filter((k) => k !== '');
|
|
818
|
+
return appended.length === 0 ? base : [...new Set([...base, ...appended])];
|
|
819
|
+
}
|
|
820
|
+
/** The user-declared credential paths, as configured (may be empty). */
|
|
821
|
+
liveCredentialPaths() {
|
|
822
|
+
const read = this.options.readCredentialPaths;
|
|
823
|
+
const list = read !== undefined ? read() : (this.options.credentialPaths ?? []);
|
|
824
|
+
return Array.isArray(list) ? list : [];
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* The configured additions to the two argument-key sets, or `undefined` when nothing was
|
|
828
|
+
* configured so `scan-text` keeps its built-in pair without rebuilding anything.
|
|
829
|
+
*
|
|
830
|
+
* Read per call: both settings are volatile, and a scan rule that only applies after a
|
|
831
|
+
* reload is a scan rule nobody can reason about while debugging a false positive.
|
|
832
|
+
*/
|
|
833
|
+
liveScanKeys() {
|
|
834
|
+
const readContent = this.options.readExtraContentArgKeys;
|
|
835
|
+
const readOperation = this.options.readExtraOperationArgKeys;
|
|
836
|
+
const content = readContent !== undefined ? readContent() : (this.options.extraContentArgKeys ?? []);
|
|
837
|
+
const operation = readOperation !== undefined ? readOperation() : (this.options.extraOperationArgKeys ?? []);
|
|
838
|
+
const clean = (list) => Array.isArray(list) && list.length > 0 ? list : undefined;
|
|
839
|
+
const c = clean(content);
|
|
840
|
+
const o = clean(operation);
|
|
841
|
+
return c === undefined && o === undefined ? undefined : { ...(c === undefined ? {} : { content: c }), ...(o === undefined ? {} : { operation: o }) };
|
|
736
842
|
}
|
|
737
843
|
/**
|
|
738
844
|
* The preset deny-keyword layer (inherited from dsh-approval-gate): a keyword
|
|
@@ -752,7 +858,7 @@ export class PermGateRuntime {
|
|
|
752
858
|
const keywords = this.liveDenyKeywords();
|
|
753
859
|
if (keywords.length === 0)
|
|
754
860
|
return undefined;
|
|
755
|
-
const text =
|
|
861
|
+
const text = keywordScanText(exec.name, exec.arguments ?? {}, exec.commandText, this.liveScanKeys());
|
|
756
862
|
if (text === '')
|
|
757
863
|
return undefined;
|
|
758
864
|
const haystacks = [text];
|
|
@@ -1008,7 +1114,7 @@ export class PermGateRuntime {
|
|
|
1008
1114
|
// untouched and every later stage stays behind this veto.
|
|
1009
1115
|
const keyword = this.denyKeywordHit(exec);
|
|
1010
1116
|
if (keyword !== undefined) {
|
|
1011
|
-
const reason = `deny-keyword: matches
|
|
1117
|
+
const reason = `deny-keyword: matches blacklist entry "${keyword}"`;
|
|
1012
1118
|
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'deny', source: 'deny-keyword', reason, at: Date.now() }));
|
|
1013
1119
|
this.recordEvent(exec, 'deny', reason, { verdict: 'deny-keyword' });
|
|
1014
1120
|
return { kind: 'deny', reason };
|
|
@@ -1115,7 +1221,7 @@ export class PermGateRuntime {
|
|
|
1115
1221
|
if (keyword !== undefined) {
|
|
1116
1222
|
return {
|
|
1117
1223
|
action: 'deny',
|
|
1118
|
-
reason: `deny-keyword: matches
|
|
1224
|
+
reason: `deny-keyword: matches blacklist entry "${keyword}"`,
|
|
1119
1225
|
source: 'deny-keyword',
|
|
1120
1226
|
};
|
|
1121
1227
|
}
|
|
@@ -1231,17 +1337,26 @@ export class PermGateRuntime {
|
|
|
1231
1337
|
this.clearCall(exec, reason, 'cleanup-safe', 'classifier');
|
|
1232
1338
|
return undefined;
|
|
1233
1339
|
}
|
|
1340
|
+
// The `permissive-snet` tier grades without a model. The mechanical screen has already
|
|
1341
|
+
// run (its doubts ride along in the ask context) and the deterministic screens above
|
|
1342
|
+
// still apply, but from here the ask goes to a person as-is — that tier exists for runs
|
|
1343
|
+
// with no reviewer LLM, or where the tokens are not worth spending and a human answer is
|
|
1344
|
+
// preferred over an auto-decision.
|
|
1345
|
+
if (this.isSafetyNetAsk(exec))
|
|
1346
|
+
return decision;
|
|
1234
1347
|
if (!permissive.strategies.llmAssist)
|
|
1235
1348
|
return decision;
|
|
1236
1349
|
// One live read of the classifier setup. Without a usable receiver (a
|
|
1237
1350
|
// configured custom endpoint, or the host llm service in host mode) the
|
|
1238
1351
|
// ask goes straight to the human seam (fail-closed) — unless a risk hook
|
|
1239
|
-
// is injected (tests / direct embedding replace the grader entirely).
|
|
1352
|
+
// is injected (tests / direct embedding replace the grader entirely). With
|
|
1353
|
+
// degraded review on (`rulesFallback`), there is no human to wait for either,
|
|
1354
|
+
// so the deterministic nets decide it: 无模型时劣化成规则闸门.
|
|
1240
1355
|
const cfg = this.options.readClassifyConfig?.();
|
|
1241
1356
|
const useHost = this.liveClassifySource() === 'host' && this.options.hostLlm !== undefined;
|
|
1242
1357
|
if (this.options.riskHook === undefined && !useHost
|
|
1243
1358
|
&& (cfg?.endpoint === undefined || cfg?.endpoint === '' || cfg?.model === undefined || cfg?.model === '')) {
|
|
1244
|
-
return decision;
|
|
1359
|
+
return this.isRulesFallback() ? this.resolveRulesDegrade(exec, decision) : decision;
|
|
1245
1360
|
}
|
|
1246
1361
|
// One frozen view of this call, then the grader's projection of it: bounded,
|
|
1247
1362
|
// redacted downstream, and carrying the scope anchor its own prompt assumes
|
|
@@ -1259,6 +1374,7 @@ export class PermGateRuntime {
|
|
|
1259
1374
|
ruleReason: decision.reason,
|
|
1260
1375
|
callId: exec.callId,
|
|
1261
1376
|
knobs: { permissionPreset: this.presetOf(exec) },
|
|
1377
|
+
...this.scriptReviewFor(exec, workspace),
|
|
1262
1378
|
// Salted per session: a bare digest of a short command is trivially
|
|
1263
1379
|
// dictionary-reversible and would let one event be joined to another
|
|
1264
1380
|
// session's. Raw args never reach the log either.
|
|
@@ -1266,12 +1382,17 @@ export class PermGateRuntime {
|
|
|
1266
1382
|
hash: (text) => createHash('sha256').update(text).digest('hex').slice(0, 16),
|
|
1267
1383
|
});
|
|
1268
1384
|
const eventFields = toEventFields(askCtx);
|
|
1385
|
+
// The grader's own projection is the single source of the attached script bodies, so
|
|
1386
|
+
// what leaves the host is exactly what was redacted and bounded (and exactly what
|
|
1387
|
+
// the human card says was attached).
|
|
1388
|
+
const grader = toGraderPayload(askCtx);
|
|
1269
1389
|
const req = {
|
|
1270
1390
|
tool: askCtx.tool,
|
|
1271
1391
|
args: askCtx.args ?? {},
|
|
1272
1392
|
reason: decision.reason,
|
|
1273
1393
|
workspaceRoot: askCtx.workspaceRoot,
|
|
1274
1394
|
targetResolvable: askCtx.targetResolvable,
|
|
1395
|
+
...(grader.script_contents === undefined ? {} : { scriptContents: grader.script_contents }),
|
|
1275
1396
|
};
|
|
1276
1397
|
let risk;
|
|
1277
1398
|
if (this.options.riskHook !== undefined) {
|
|
@@ -1284,6 +1405,11 @@ export class PermGateRuntime {
|
|
|
1284
1405
|
else {
|
|
1285
1406
|
risk = await classifyRisk(cfg ?? {}, req);
|
|
1286
1407
|
}
|
|
1408
|
+
// The grader's own sentence, sanitized for a human card and an audit line (issue #3):
|
|
1409
|
+
// a confirmation prompt that only says "a rule asked" hides the one thing the person
|
|
1410
|
+
// wants to read, and the model already produced it.
|
|
1411
|
+
const note = graderNote(risk);
|
|
1412
|
+
const noteClause = note === undefined ? '' : ` · model: ${note}`;
|
|
1287
1413
|
// Opaque payload: the grader may not clear what it cannot see. Checked here,
|
|
1288
1414
|
// ahead of the `safe` branch, as a deterministic veto over the model rather
|
|
1289
1415
|
// than a category it is asked to pick — an over-confident `safe` must not
|
|
@@ -1306,9 +1432,9 @@ export class PermGateRuntime {
|
|
|
1306
1432
|
: askCtx.reversibility === 'I'
|
|
1307
1433
|
? 'irreversible by rule → ask'
|
|
1308
1434
|
: 'payload=opaque → ask';
|
|
1309
|
-
const vetoReason = `${decision.reason} [${why}, grader said ${risk.kind}] · ${humanCardLine(askCtx)}`;
|
|
1435
|
+
const vetoReason = `${decision.reason} [${why}, grader said ${risk.kind}]${noteClause} · ${humanCardLine(askCtx)}`;
|
|
1310
1436
|
this.trackAsk(exec, vetoReason, undefined, askCtx);
|
|
1311
|
-
return this.askDecision(askCtx, vetoReason);
|
|
1437
|
+
return this.askDecision(askCtx, vetoReason, false, note);
|
|
1312
1438
|
}
|
|
1313
1439
|
}
|
|
1314
1440
|
if (risk.kind === 'safe') {
|
|
@@ -1335,9 +1461,9 @@ export class PermGateRuntime {
|
|
|
1335
1461
|
// Hard categories stay distinct from `neutral` in one way that matters:
|
|
1336
1462
|
// they are NEVER learnable, so repeated confirmations cannot sediment
|
|
1337
1463
|
// them into an auto-allow.
|
|
1338
|
-
const hardReason = `${decision.reason} [llm-assist risky:${risk.category} → ask] · ${humanCardLine(askCtx)}`;
|
|
1464
|
+
const hardReason = `${decision.reason} [llm-assist risky:${risk.category} → ask]${noteClause} · ${humanCardLine(askCtx)}`;
|
|
1339
1465
|
this.trackAsk(exec, hardReason, undefined, askCtx);
|
|
1340
|
-
return this.askDecision(askCtx, hardReason, true);
|
|
1466
|
+
return this.askDecision(askCtx, hardReason, true, note);
|
|
1341
1467
|
}
|
|
1342
1468
|
const learning = this.liveRiskLearning();
|
|
1343
1469
|
const fp = operationFingerprint(exec.name, exec.arguments ?? {}, exec.commandText);
|
|
@@ -1358,21 +1484,296 @@ export class PermGateRuntime {
|
|
|
1358
1484
|
}
|
|
1359
1485
|
this.pending.set(canonicalizeCall(exec.name, exec.arguments ?? {}), { key, fp, ctx: decision.reason });
|
|
1360
1486
|
}
|
|
1361
|
-
const neutralReason = `${decision.reason} [llm-assist risky:neutral] · ${humanCardLine(askCtx)}`;
|
|
1487
|
+
const neutralReason = `${decision.reason} [llm-assist risky:neutral]${noteClause} · ${humanCardLine(askCtx)}`;
|
|
1362
1488
|
// The refined reason supersedes the raw ask, and the candidate key lets the
|
|
1363
1489
|
// terminal event report the post-approval learning progress.
|
|
1364
1490
|
this.trackAsk(exec, neutralReason, learning.enabled ? key : undefined, askCtx);
|
|
1365
|
-
return this.askDecision(askCtx, neutralReason, true);
|
|
1491
|
+
return this.askDecision(askCtx, neutralReason, true, note);
|
|
1366
1492
|
}
|
|
1367
1493
|
// unresolved: keep the ask, learn nothing — and label it. Without a marker
|
|
1368
1494
|
// here, "the grader timed out" and "the grader called this risky" reach the
|
|
1369
1495
|
// human as the same sentence, so an outage silently impersonates a
|
|
1370
1496
|
// security judgement. Tagged via `reason` rather than a new event `kind`,
|
|
1371
1497
|
// because `presentation()` maps an unknown kind to a green ALLOW.
|
|
1498
|
+
//
|
|
1499
|
+
// An outage is exactly the case degraded review exists for: the reviewer was
|
|
1500
|
+
// configured but produced nothing, so the call goes to the deterministic nets
|
|
1501
|
+
// rather than a popup nobody will answer.
|
|
1502
|
+
if (this.isRulesFallback())
|
|
1503
|
+
return this.resolveRulesDegrade(exec, decision, askCtx);
|
|
1372
1504
|
const unresolvedReason = `${decision.reason} [llm-assist unresolved → ask] · ${humanCardLine(askCtx)}`;
|
|
1373
1505
|
this.trackAsk(exec, unresolvedReason, undefined, askCtx);
|
|
1374
1506
|
return this.askDecision(askCtx, unresolvedReason);
|
|
1375
1507
|
}
|
|
1508
|
+
/**
|
|
1509
|
+
* YOLO resolution of an `ask`: never raise a human. A **dangerous** ask — an
|
|
1510
|
+
* irreversible / unlearnable / opaque call (deterministic), or an `llmAssist`
|
|
1511
|
+
* hard-risk verdict when a grader is reachable — is **blocked** (`deny`), and the
|
|
1512
|
+
* caller feeds the reason back to the main LLM as a user message. Every other ask
|
|
1513
|
+
* is **allowed** (returns `undefined` → passthrough), so ordinary work keeps moving
|
|
1514
|
+
* without a human. This is the mirror of `askAnswerable`'s never-passthrough (which
|
|
1515
|
+
* blanket-allows under `approval: never`): YOLO allows the benign but actively
|
|
1516
|
+
* intercepts the dangerous — "尽力自动拦截所有危险命令".
|
|
1517
|
+
*/
|
|
1518
|
+
async resolveYolo(exec, decision) {
|
|
1519
|
+
const callId = randomId();
|
|
1520
|
+
const askCtx = this.unattendedContextFor(exec, decision);
|
|
1521
|
+
const judged = this.mechanicalJudge(askCtx);
|
|
1522
|
+
let riskCategory;
|
|
1523
|
+
// Whether the reviewer actually spoke. YOLO advertises "the model answers in the
|
|
1524
|
+
// person's place"; when there is no grader, or it gave up, the tier still refuses to ask
|
|
1525
|
+
// a human and settles on the mechanical screen — but the audit and the approvals history
|
|
1526
|
+
// must say so, or the two human-free tiers silently become one.
|
|
1527
|
+
let reviewerAnswered = true;
|
|
1528
|
+
// The grader's sentence, kept for the block note: in YOLO there is no popup, so this
|
|
1529
|
+
// text plus the feedback opinion is the only explanation the main LLM ever gets.
|
|
1530
|
+
let modelNote;
|
|
1531
|
+
// The shared mechanical floor runs first, and its verdict stands: these are the
|
|
1532
|
+
// signals a model can never clear, and asking costs a call for nothing. Only what it
|
|
1533
|
+
// cannot name goes to the grader — and there only to ESCALATE to a block. safe /
|
|
1534
|
+
// neutral / unresolved / no grader adds no danger signal (the yolo "keep moving"
|
|
1535
|
+
// contract; an absent grader falls through to the mechanical conclusion).
|
|
1536
|
+
if (judged === null) {
|
|
1537
|
+
const graded = await this.modelJudge(askCtx, decision.reason);
|
|
1538
|
+
reviewerAnswered = graded.answered;
|
|
1539
|
+
if (graded.risky) {
|
|
1540
|
+
riskCategory = graded.category;
|
|
1541
|
+
modelNote = graded.note;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
const danger = judged !== null || riskCategory !== undefined;
|
|
1545
|
+
if (!danger) {
|
|
1546
|
+
const reason = reviewerAnswered
|
|
1547
|
+
? `yolo: allow (no danger signal) — ${decision.reason}`
|
|
1548
|
+
: `yolo: allow (no reviewer answered; mechanical screen found nothing) — ${decision.reason}`;
|
|
1549
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
1550
|
+
this.recordEvent(exec, 'auto', reason, { verdict: reviewerAnswered ? 'yolo-allow' : 'yolo-allow-no-reviewer', risk: 'safe' });
|
|
1551
|
+
this.untrackAsk(exec);
|
|
1552
|
+
return undefined;
|
|
1553
|
+
}
|
|
1554
|
+
const why = riskCategory !== undefined
|
|
1555
|
+
? `风险模型判定 ${riskCategory}${modelNote === undefined ? '' : `:${modelNote}`}`
|
|
1556
|
+
: judged.why;
|
|
1557
|
+
// Which danger signal fired, as a tag the feedback layer can name to the main LLM
|
|
1558
|
+
// (`[yolo-danger:…]` → 依据 in 工具审核意见) and classify on: an opaque payload must
|
|
1559
|
+
// read as *manual_only*, everything else as a hard stop rather than "narrow it".
|
|
1560
|
+
const dangerTag = `[yolo-danger:${riskCategory !== undefined
|
|
1561
|
+
? `model-risk.${riskCategory}`.toLowerCase().replace(/[^a-z0-9._-]/g, '-')
|
|
1562
|
+
: judged.signal}]`;
|
|
1563
|
+
const cmd = humanCommand(askCtx) || askCtx.tool;
|
|
1564
|
+
const reason = `yolo: block ${dangerTag} — ${decision.reason}${modelNote === undefined ? '' : ` · model: ${modelNote}`}`;
|
|
1565
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'deny', source: 'classifier', reason, at: Date.now() }));
|
|
1566
|
+
this.recordEvent(exec, 'deny', reason, { verdict: 'yolo-block', ...(riskCategory !== undefined ? { category: riskCategory } : {}) });
|
|
1567
|
+
this.untrackAsk(exec);
|
|
1568
|
+
return {
|
|
1569
|
+
kind: 'deny',
|
|
1570
|
+
reason,
|
|
1571
|
+
displayReason: {
|
|
1572
|
+
en: `Blocked dangerous command: ${cmd}. Reason: ${riskCategory ?? 'irreversible/opaque'}. Use a safer, reversible approach or re-request with justification.`,
|
|
1573
|
+
zh: `已拦截危险命令:${cmd}。原因:${why}。请换用更安全、可逆的方式,或在说明理由后重新发起。`,
|
|
1574
|
+
},
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* The review model's pass over one ask, shared by every tier that lets a model
|
|
1579
|
+
* speak for the human. `answered: false` means no reviewer is configured or it was
|
|
1580
|
+
* unreachable — what that *means* is the one thing the tiers disagree about, so the
|
|
1581
|
+
* caller decides (YOLO falls back to the mechanical conclusion, the human tiers keep
|
|
1582
|
+
* the popup, the mechanical tier never gets here).
|
|
1583
|
+
*/
|
|
1584
|
+
async modelJudge(askCtx, reason) {
|
|
1585
|
+
if (!this.classifierUsable())
|
|
1586
|
+
return { answered: false, risky: false };
|
|
1587
|
+
const req = {
|
|
1588
|
+
tool: askCtx.tool,
|
|
1589
|
+
args: askCtx.args ?? {},
|
|
1590
|
+
reason,
|
|
1591
|
+
workspaceRoot: askCtx.workspaceRoot,
|
|
1592
|
+
targetResolvable: askCtx.targetResolvable,
|
|
1593
|
+
// Same bodies the human would have been shown: in a human-free tier there is no
|
|
1594
|
+
// popup, but a script run is graded on its code either way.
|
|
1595
|
+
...(toGraderPayload(askCtx).script_contents === undefined ? {} : { scriptContents: toGraderPayload(askCtx).script_contents }),
|
|
1596
|
+
};
|
|
1597
|
+
const cfg = this.options.readClassifyConfig?.();
|
|
1598
|
+
const useHost = this.liveClassifySource() === 'host' && this.options.hostLlm !== undefined;
|
|
1599
|
+
let risk;
|
|
1600
|
+
if (this.options.riskHook !== undefined) {
|
|
1601
|
+
risk = await this.options.riskHook(req);
|
|
1602
|
+
}
|
|
1603
|
+
else if (useHost) {
|
|
1604
|
+
const selection = this.hostModel() ?? DEFAULT_HOST_MODEL;
|
|
1605
|
+
risk = await classifyRiskWith((system, user) => completeViaHost(this.options.hostLlm, selection, system, user, cfg?.timeoutMs ?? 20_000), req);
|
|
1606
|
+
}
|
|
1607
|
+
else {
|
|
1608
|
+
risk = await classifyRisk(cfg ?? {}, req);
|
|
1609
|
+
}
|
|
1610
|
+
// `neutral` is the grader declining to characterize the call, not a risk signal, and
|
|
1611
|
+
// `unresolved` means it never got an answer at all. Only the first counts as "the
|
|
1612
|
+
// reviewer spoke": a tier that advertises a model must not report one when it got
|
|
1613
|
+
// nothing, which is why `answered` is what the caller labels the outcome with.
|
|
1614
|
+
if (risk.kind === 'risky' && risk.category !== 'neutral') {
|
|
1615
|
+
return { answered: true, risky: true, category: risk.category, note: graderNote(risk) };
|
|
1616
|
+
}
|
|
1617
|
+
return { answered: risk.kind !== 'unresolved', risky: false };
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Which implementation, if any, owns this call's permission preset.
|
|
1621
|
+
*
|
|
1622
|
+
* One preset, one switch: `permissive-mech` is the mechanical takeover of asks,
|
|
1623
|
+
* `permissive-snet` the model-free ask, `permissive-yolo` the model-backed human-free
|
|
1624
|
+
* tier. Any other preset — including one whose switch is off — returns `null` and runs the
|
|
1625
|
+
* ordinary flow, because a tier with no implementation behind it must not start behaving
|
|
1626
|
+
* like some other tier.
|
|
1627
|
+
*/
|
|
1628
|
+
unattendedModeOf(exec) {
|
|
1629
|
+
return resolveTierMode(this.presetOf(exec), {
|
|
1630
|
+
yolo: this.isYolo(),
|
|
1631
|
+
safenetPass: this.options.readSafenetPass?.() ?? (this.options.safenetPass ?? false),
|
|
1632
|
+
safenetAsk: this.options.readSafenetAsk?.() ?? (this.options.safenetAsk ?? false),
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
/** The mechanical human-free tier for this call (`permissive-mech`, switch on). */
|
|
1636
|
+
isSafetyNet(exec) {
|
|
1637
|
+
return this.unattendedModeOf(exec) === 'safety-net-passthrough';
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* The model-free ask tier for this call (`permissive-snet`, switch on): the mechanical
|
|
1641
|
+
* screen still runs and its doubts ride along with the ask, but no grader is consulted
|
|
1642
|
+
* and the decision stays with a person.
|
|
1643
|
+
*/
|
|
1644
|
+
isSafetyNetAsk(exec) {
|
|
1645
|
+
return this.unattendedModeOf(exec) === 'safety-net-ask';
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* One frozen view of a call for the human-free tiers: the same context the popup
|
|
1649
|
+
* would have shown (script bodies included), plus the salted digest fields the
|
|
1650
|
+
* grader path expects.
|
|
1651
|
+
*/
|
|
1652
|
+
unattendedContextFor(exec, decision) {
|
|
1653
|
+
const workspace = cwdOf(exec);
|
|
1654
|
+
return buildAskContext({
|
|
1655
|
+
tool: exec.name,
|
|
1656
|
+
args: exec.arguments ?? {},
|
|
1657
|
+
commandText: exec.commandText,
|
|
1658
|
+
cwd: workspace,
|
|
1659
|
+
workspaceRoot: workspace,
|
|
1660
|
+
files: eventFiles(exec),
|
|
1661
|
+
ruleReason: decision.reason,
|
|
1662
|
+
callId: exec.callId,
|
|
1663
|
+
knobs: { permissionPreset: this.presetOf(exec) },
|
|
1664
|
+
...this.scriptReviewFor(exec, workspace),
|
|
1665
|
+
salt: sessionIdOf(exec),
|
|
1666
|
+
hash: (text) => createHash('sha256').update(text).digest('hex').slice(0, 16),
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* The mechanical floor — what the deterministic nets can NAME about an ask, or `null`
|
|
1671
|
+
* when nothing is nameable. Every tier classifies with this one judge; the tiers differ
|
|
1672
|
+
* only in what they then do with the answer:
|
|
1673
|
+
*
|
|
1674
|
+
* | tier | 机械点名了危险 | 机械没点名 |
|
|
1675
|
+
* | --- | --- | --- |
|
|
1676
|
+
* | 人工确认(`permissive` / `permissive-full`) | 仍然问人,把信号作为疑点列出 | 问人(`llmAssist` 开着时模型可先精化) |
|
|
1677
|
+
* | `safenetAsk`(`permissive-snet`) | 问人,列疑点 | 问人,**一次模型都不叫** |
|
|
1678
|
+
* | `safenetPass`(`permissive-mech`) | 直接拦 + 回喂 | 直接放行,一次模型都不叫 |
|
|
1679
|
+
* | `yolo`(`permissive-yolo`) | 直接拦(模型无法洗白,也不花这次调用) | 问模型;模型不在则按机械结论处置并标注 |
|
|
1680
|
+
*
|
|
1681
|
+
* The human tier not auto-blocking on a hit is deliberate: the same net that reads
|
|
1682
|
+
* `rm -rf` of a home directory as irreversible also reads a project's own aggressive
|
|
1683
|
+
* cleanup that way, and a person can tell those apart. Nobody is watching in the two
|
|
1684
|
+
* human-free tiers, so there the cost of a false positive is accepted.
|
|
1685
|
+
*/
|
|
1686
|
+
mechanicalJudge(askCtx) {
|
|
1687
|
+
if (!askCtx.targetResolvable) {
|
|
1688
|
+
return { signal: 'opaque-payload', why: '载荷不可读(编码/工作区外脚本/未见内容)', english: 'unreadable payload' };
|
|
1689
|
+
}
|
|
1690
|
+
if (askCtx.unlearnable) {
|
|
1691
|
+
return { signal: 'data-destruction', why: '不可逆数据销毁', english: 'irreversible data destruction' };
|
|
1692
|
+
}
|
|
1693
|
+
if (askCtx.reversibility === 'I') {
|
|
1694
|
+
return { signal: 'irreversible-op', why: '不可逆操作', english: 'irreversible operation' };
|
|
1695
|
+
}
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* The mechanical tier (`permissive-mech`, behind the `safenetPass` switch) — a
|
|
1700
|
+
* human-free ask settled by the deterministic nets ALONE, with **no model call at all**.
|
|
1701
|
+
* Two equivalent ways to name it: the reviewed form of `approval: never` (that preset
|
|
1702
|
+
* passes every ask with nothing judging it; this one passes only what the nets cannot
|
|
1703
|
+
* condemn), and YOLO with the model taken away — which is also what YOLO itself falls
|
|
1704
|
+
* back to when no reviewer answers.
|
|
1705
|
+
*
|
|
1706
|
+
* It shares {@link mechanicalJudge} with every other tier's first pass: an opaque
|
|
1707
|
+
* payload, irreversible act, or unlearnable destruction is blocked and the reason fed
|
|
1708
|
+
* back to the main LLM. Everything the nets cannot name simply runs. It never softens a
|
|
1709
|
+
* deterministic deny — those arrive already denied — and it never raises a human prompt.
|
|
1710
|
+
*/
|
|
1711
|
+
resolveSafetyNet(exec, decision) {
|
|
1712
|
+
const callId = randomId();
|
|
1713
|
+
const askCtx = this.unattendedContextFor(exec, decision);
|
|
1714
|
+
const judge = this.mechanicalJudge(askCtx);
|
|
1715
|
+
if (judge === null) {
|
|
1716
|
+
const reason = `safety-net-passthrough: allow (no deterministic danger) — ${decision.reason}`;
|
|
1717
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source: 'rule', reason, at: Date.now() }));
|
|
1718
|
+
this.recordEvent(exec, 'auto', reason, { verdict: 'safety-net-passthrough-allow', risk: 'safe' });
|
|
1719
|
+
this.untrackAsk(exec);
|
|
1720
|
+
this.clearCall(exec, reason, 'safety-net-passthrough-allow', 'rule');
|
|
1721
|
+
return undefined;
|
|
1722
|
+
}
|
|
1723
|
+
const cmd = humanCommand(askCtx) || askCtx.tool;
|
|
1724
|
+
const reason = `safety-net-passthrough: block [safety-net-danger:${judge.signal}] — ${decision.reason}`;
|
|
1725
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'deny', source: 'rule', reason, at: Date.now() }));
|
|
1726
|
+
this.recordEvent(exec, 'deny', reason, { verdict: 'safety-net-passthrough-block' });
|
|
1727
|
+
this.untrackAsk(exec);
|
|
1728
|
+
return {
|
|
1729
|
+
kind: 'deny',
|
|
1730
|
+
reason,
|
|
1731
|
+
displayReason: {
|
|
1732
|
+
en: `Unattended mechanical review blocked ${cmd} (${judge.english}). Run it yourself, or use a safer, reversible approach.`,
|
|
1733
|
+
zh: `无人值守机械审查已拦截:${cmd}(${judge.why})。请自行手动执行,或改用更安全、可逆的方式。`,
|
|
1734
|
+
},
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Degraded resolution of an `ask` when **no reviewer answered it** — either none is
|
|
1739
|
+
* configured or the configured one could not reach a verdict. Instead of leaving the
|
|
1740
|
+
* call on a human seam nobody is watching, it decides on the deterministic nets alone,
|
|
1741
|
+
* the way a static policy gate does: an irreversible (`I`-tier) / unlearnable /
|
|
1742
|
+
* opaque call is **blocked** (the caller feeds the reason back to the main LLM), and
|
|
1743
|
+
* everything else runs. Unlike {@link resolveYolo} no model is consulted at all — this
|
|
1744
|
+
* is what automatic review becomes when the model is missing, not a mode the operator
|
|
1745
|
+
* picks while one is working. Never softens a deterministic deny: those arrive here
|
|
1746
|
+
* already denied, and a rule-only `ask` is by construction something the rules could
|
|
1747
|
+
* not condemn.
|
|
1748
|
+
*/
|
|
1749
|
+
resolveRulesDegrade(exec, decision, ctx) {
|
|
1750
|
+
const callId = randomId();
|
|
1751
|
+
const askCtx = ctx ?? this.contextFor(exec, decision.reason);
|
|
1752
|
+
const judge = this.mechanicalJudge(askCtx);
|
|
1753
|
+
// Tag names the signal so the feedback layer can classify it: an opaque payload is
|
|
1754
|
+
// *manual_only* (nobody read it, a human should run it), the rest is a hard stop.
|
|
1755
|
+
if (judge === null) {
|
|
1756
|
+
const reason = `rules-fallback: allow (no reviewer, no deterministic danger) — ${decision.reason}`;
|
|
1757
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source: 'rule', reason, at: Date.now() }));
|
|
1758
|
+
this.recordEvent(exec, 'auto', reason, { verdict: 'rules-fallback-allow', risk: 'safe' });
|
|
1759
|
+
this.untrackAsk(exec);
|
|
1760
|
+
this.clearCall(exec, reason, 'rules-fallback-allow', 'rule');
|
|
1761
|
+
return undefined;
|
|
1762
|
+
}
|
|
1763
|
+
const cmd = humanCommand(askCtx) || askCtx.tool;
|
|
1764
|
+
const reason = `rules-fallback: block [rules-fallback-danger:${judge.signal}] — ${decision.reason}`;
|
|
1765
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'deny', source: 'rule', reason, at: Date.now() }));
|
|
1766
|
+
this.recordEvent(exec, 'deny', reason, { verdict: 'rules-fallback-block' });
|
|
1767
|
+
this.untrackAsk(exec);
|
|
1768
|
+
return {
|
|
1769
|
+
kind: 'deny',
|
|
1770
|
+
reason,
|
|
1771
|
+
displayReason: {
|
|
1772
|
+
en: `No risk reviewer available, so the rule gate decided this call: blocked ${cmd} (${judge.signal === 'opaque-payload' ? 'unreadable payload' : 'irreversible'}). Run it yourself, or use a safer, reversible approach.`,
|
|
1773
|
+
zh: `无可用审查模型,已按规则闸门处置:拦截 ${cmd}(${judge.why})。请自行手动执行,或改用更安全、可逆的方式。`,
|
|
1774
|
+
},
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1376
1777
|
/**
|
|
1377
1778
|
* One frozen view of a call, built the same way for every ask path so the
|
|
1378
1779
|
* popup cannot disagree with what the grader was shown.
|
|
@@ -1389,6 +1790,7 @@ export class PermGateRuntime {
|
|
|
1389
1790
|
ruleReason: reason,
|
|
1390
1791
|
callId: exec.callId,
|
|
1391
1792
|
knobs: { permissionPreset: this.presetOf(exec) },
|
|
1793
|
+
...this.scriptReviewFor(exec, workspace),
|
|
1392
1794
|
});
|
|
1393
1795
|
}
|
|
1394
1796
|
/**
|
|
@@ -1404,17 +1806,20 @@ export class PermGateRuntime {
|
|
|
1404
1806
|
* each button costs. Without the command line a person is asked to approve an
|
|
1405
1807
|
* operation they cannot read, which is the complaint this task exists to fix.
|
|
1406
1808
|
*/
|
|
1407
|
-
askDecision(askCtx, reason, modelFlagged = false) {
|
|
1809
|
+
askDecision(askCtx, reason, modelFlagged = false, modelNote) {
|
|
1408
1810
|
// `reason` stays the audit string: short, English, machine-tokened. Everything a
|
|
1409
1811
|
// person reads goes into `displayReason`, which the host resolves by UI locale
|
|
1410
1812
|
// (`zh` here; the other seven fall back to `en`) and never persists.
|
|
1411
1813
|
const kinds = askDoubtKinds(askCtx, { modelFlagged });
|
|
1814
|
+
// The grader's own words ride along when there are any (issue #3) — marked as what
|
|
1815
|
+
// they are, an opinion from a second model, never a rule finding.
|
|
1816
|
+
const tail = (word) => (modelNote === undefined || modelNote === '' ? '' : ` · ${word}${modelNote}`);
|
|
1412
1817
|
return {
|
|
1413
1818
|
kind: 'ask',
|
|
1414
1819
|
reason,
|
|
1415
1820
|
displayReason: {
|
|
1416
|
-
zh: humanBrief(askCtx, 'zh', kinds)
|
|
1417
|
-
en: humanBrief(askCtx, 'en', kinds)
|
|
1821
|
+
zh: `${humanBrief(askCtx, 'zh', kinds)}${tail('模型审查意见:')}`,
|
|
1822
|
+
en: `${humanBrief(askCtx, 'en', kinds)}${tail('risk model says: ')}`,
|
|
1418
1823
|
},
|
|
1419
1824
|
};
|
|
1420
1825
|
}
|
|
@@ -1760,42 +2165,6 @@ function keywordMatcher(keyword) {
|
|
|
1760
2165
|
keywordMatchers.set(keyword, re);
|
|
1761
2166
|
return re;
|
|
1762
2167
|
}
|
|
1763
|
-
/**
|
|
1764
|
-
* The text a blacklist keyword may match: the shell command plus the call's
|
|
1765
|
-
* scalar arguments, excluding document-body fields. Whitespace is collapsed so
|
|
1766
|
-
* a command written with extra spaces still reads as the command it is.
|
|
1767
|
-
*/
|
|
1768
|
-
function denyScanText(exec) {
|
|
1769
|
-
const parts = [];
|
|
1770
|
-
const walk = (node, depth) => {
|
|
1771
|
-
if (depth > 6)
|
|
1772
|
-
return;
|
|
1773
|
-
if (Array.isArray(node)) {
|
|
1774
|
-
for (const item of node) {
|
|
1775
|
-
if (typeof item === 'string')
|
|
1776
|
-
parts.push(item);
|
|
1777
|
-
else
|
|
1778
|
-
walk(item, depth + 1);
|
|
1779
|
-
}
|
|
1780
|
-
return;
|
|
1781
|
-
}
|
|
1782
|
-
if (typeof node !== 'object' || node === null)
|
|
1783
|
-
return;
|
|
1784
|
-
for (const [key, value] of Object.entries(node)) {
|
|
1785
|
-
if (typeof value === 'string') {
|
|
1786
|
-
if (!CONTENT_ARG_KEYS.has(key))
|
|
1787
|
-
parts.push(value);
|
|
1788
|
-
}
|
|
1789
|
-
else {
|
|
1790
|
-
walk(value, depth + 1);
|
|
1791
|
-
}
|
|
1792
|
-
}
|
|
1793
|
-
};
|
|
1794
|
-
walk(exec.arguments ?? {}, 0);
|
|
1795
|
-
if (typeof exec.commandText === 'string' && exec.commandText !== '')
|
|
1796
|
-
parts.unshift(exec.commandText);
|
|
1797
|
-
return parts.join(' ').replace(/\s+/g, ' ');
|
|
1798
|
-
}
|
|
1799
2168
|
function randomId() {
|
|
1800
2169
|
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
1801
2170
|
}
|