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.
Files changed (47) hide show
  1. package/CHANGELOG.ja.md +54 -1
  2. package/CHANGELOG.ko.md +54 -1
  3. package/CHANGELOG.md +134 -4
  4. package/README.ja.md +43 -0
  5. package/README.ko.md +43 -1
  6. package/README.md +73 -0
  7. package/README.zh.md +41 -0
  8. package/THIRD_PARTY_NOTICES.md +44 -0
  9. package/cordis.patch.yml +16 -1
  10. package/dsh.plugin.json +1 -1
  11. package/lib/ask-context.d.ts +49 -1
  12. package/lib/ask-context.js +89 -5
  13. package/lib/client.js +714 -86
  14. package/lib/client.js.map +1 -1
  15. package/lib/config.d.ts +243 -83
  16. package/lib/config.js +84 -41
  17. package/lib/credential-paths.d.ts +35 -0
  18. package/lib/credential-paths.js +119 -0
  19. package/lib/engine.d.ts +4 -6
  20. package/lib/engine.js +53 -42
  21. package/lib/evaluate.d.ts +20 -0
  22. package/lib/evaluate.js +41 -13
  23. package/lib/feedback.d.ts +42 -0
  24. package/lib/feedback.js +85 -0
  25. package/lib/index.d.ts +2 -2
  26. package/lib/index.js +143 -17
  27. package/lib/irreversible.js +109 -1
  28. package/lib/path.d.ts +31 -0
  29. package/lib/path.js +35 -0
  30. package/lib/preset-rules.d.ts +70 -0
  31. package/lib/preset-rules.js +388 -0
  32. package/lib/preset.d.ts +8 -5
  33. package/lib/preset.js +15 -6
  34. package/lib/risk.d.ts +29 -1
  35. package/lib/risk.js +36 -2
  36. package/lib/runtime.d.ts +164 -0
  37. package/lib/runtime.js +429 -55
  38. package/lib/scan-text.d.ts +69 -0
  39. package/lib/scan-text.js +162 -0
  40. package/lib/script-review.d.ts +50 -0
  41. package/lib/script-review.js +202 -0
  42. package/lib/shell.d.ts +18 -0
  43. package/lib/shell.js +103 -1
  44. package/lib/unattended.d.ts +61 -0
  45. package/lib/unattended.js +77 -0
  46. package/package.json +4 -3
  47. 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, CONTENT_ARG_KEYS } from './engine.js';
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
- return Array.isArray(override) && override.length > 0 ? override : DEFAULT_DENY_KEYWORDS;
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 = denyScanText(exec);
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 preset blacklist entry "${keyword}"`;
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 };
@@ -1068,7 +1174,12 @@ export class PermGateRuntime {
1068
1174
  // user's terminal answer comes back through tools/result anyway.
1069
1175
  if (outcome !== 'ask') {
1070
1176
  this.audit.append(makeEntry({ callId, tool: exec.name, outcome, source, reason: decision.reason, at: Date.now() }));
1071
- this.recordEvent(exec, outcome === 'allow' ? 'auto' : outcome === 'deny' ? 'deny' : 'ask', decision.reason, { verdict: source });
1177
+ // Preset deny-table entries tag their reason with `[deny-rule:<id>]`
1178
+ // (see preset-rules.ts): surface the tag as the verdict so the audit,
1179
+ // the feed and per-rule revocation attribute the exact entry instead of
1180
+ // the whole `rule` partition.
1181
+ const ruleTag = decision.stage === 'rule' ? /\[deny-rule:[a-z0-9._-]+\]/.exec(decision.reason)?.[0] : undefined;
1182
+ this.recordEvent(exec, outcome === 'allow' ? 'auto' : outcome === 'deny' ? 'deny' : 'ask', decision.reason, { verdict: ruleTag ?? source });
1072
1183
  }
1073
1184
  if (decision.action === 'deny')
1074
1185
  return { kind: 'deny', reason: decision.reason };
@@ -1110,7 +1221,7 @@ export class PermGateRuntime {
1110
1221
  if (keyword !== undefined) {
1111
1222
  return {
1112
1223
  action: 'deny',
1113
- reason: `deny-keyword: matches preset blacklist entry "${keyword}"`,
1224
+ reason: `deny-keyword: matches blacklist entry "${keyword}"`,
1114
1225
  source: 'deny-keyword',
1115
1226
  };
1116
1227
  }
@@ -1226,17 +1337,26 @@ export class PermGateRuntime {
1226
1337
  this.clearCall(exec, reason, 'cleanup-safe', 'classifier');
1227
1338
  return undefined;
1228
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;
1229
1347
  if (!permissive.strategies.llmAssist)
1230
1348
  return decision;
1231
1349
  // One live read of the classifier setup. Without a usable receiver (a
1232
1350
  // configured custom endpoint, or the host llm service in host mode) the
1233
1351
  // ask goes straight to the human seam (fail-closed) — unless a risk hook
1234
- // 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: 无模型时劣化成规则闸门.
1235
1355
  const cfg = this.options.readClassifyConfig?.();
1236
1356
  const useHost = this.liveClassifySource() === 'host' && this.options.hostLlm !== undefined;
1237
1357
  if (this.options.riskHook === undefined && !useHost
1238
1358
  && (cfg?.endpoint === undefined || cfg?.endpoint === '' || cfg?.model === undefined || cfg?.model === '')) {
1239
- return decision;
1359
+ return this.isRulesFallback() ? this.resolveRulesDegrade(exec, decision) : decision;
1240
1360
  }
1241
1361
  // One frozen view of this call, then the grader's projection of it: bounded,
1242
1362
  // redacted downstream, and carrying the scope anchor its own prompt assumes
@@ -1254,6 +1374,7 @@ export class PermGateRuntime {
1254
1374
  ruleReason: decision.reason,
1255
1375
  callId: exec.callId,
1256
1376
  knobs: { permissionPreset: this.presetOf(exec) },
1377
+ ...this.scriptReviewFor(exec, workspace),
1257
1378
  // Salted per session: a bare digest of a short command is trivially
1258
1379
  // dictionary-reversible and would let one event be joined to another
1259
1380
  // session's. Raw args never reach the log either.
@@ -1261,12 +1382,17 @@ export class PermGateRuntime {
1261
1382
  hash: (text) => createHash('sha256').update(text).digest('hex').slice(0, 16),
1262
1383
  });
1263
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);
1264
1389
  const req = {
1265
1390
  tool: askCtx.tool,
1266
1391
  args: askCtx.args ?? {},
1267
1392
  reason: decision.reason,
1268
1393
  workspaceRoot: askCtx.workspaceRoot,
1269
1394
  targetResolvable: askCtx.targetResolvable,
1395
+ ...(grader.script_contents === undefined ? {} : { scriptContents: grader.script_contents }),
1270
1396
  };
1271
1397
  let risk;
1272
1398
  if (this.options.riskHook !== undefined) {
@@ -1279,6 +1405,11 @@ export class PermGateRuntime {
1279
1405
  else {
1280
1406
  risk = await classifyRisk(cfg ?? {}, req);
1281
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}`;
1282
1413
  // Opaque payload: the grader may not clear what it cannot see. Checked here,
1283
1414
  // ahead of the `safe` branch, as a deterministic veto over the model rather
1284
1415
  // than a category it is asked to pick — an over-confident `safe` must not
@@ -1301,9 +1432,9 @@ export class PermGateRuntime {
1301
1432
  : askCtx.reversibility === 'I'
1302
1433
  ? 'irreversible by rule → ask'
1303
1434
  : 'payload=opaque → ask';
1304
- const vetoReason = `${decision.reason} [${why}, grader said ${risk.kind}] · ${humanCardLine(askCtx)}`;
1435
+ const vetoReason = `${decision.reason} [${why}, grader said ${risk.kind}]${noteClause} · ${humanCardLine(askCtx)}`;
1305
1436
  this.trackAsk(exec, vetoReason, undefined, askCtx);
1306
- return this.askDecision(askCtx, vetoReason);
1437
+ return this.askDecision(askCtx, vetoReason, false, note);
1307
1438
  }
1308
1439
  }
1309
1440
  if (risk.kind === 'safe') {
@@ -1330,9 +1461,9 @@ export class PermGateRuntime {
1330
1461
  // Hard categories stay distinct from `neutral` in one way that matters:
1331
1462
  // they are NEVER learnable, so repeated confirmations cannot sediment
1332
1463
  // them into an auto-allow.
1333
- 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)}`;
1334
1465
  this.trackAsk(exec, hardReason, undefined, askCtx);
1335
- return this.askDecision(askCtx, hardReason, true);
1466
+ return this.askDecision(askCtx, hardReason, true, note);
1336
1467
  }
1337
1468
  const learning = this.liveRiskLearning();
1338
1469
  const fp = operationFingerprint(exec.name, exec.arguments ?? {}, exec.commandText);
@@ -1353,21 +1484,296 @@ export class PermGateRuntime {
1353
1484
  }
1354
1485
  this.pending.set(canonicalizeCall(exec.name, exec.arguments ?? {}), { key, fp, ctx: decision.reason });
1355
1486
  }
1356
- const neutralReason = `${decision.reason} [llm-assist risky:neutral] · ${humanCardLine(askCtx)}`;
1487
+ const neutralReason = `${decision.reason} [llm-assist risky:neutral]${noteClause} · ${humanCardLine(askCtx)}`;
1357
1488
  // The refined reason supersedes the raw ask, and the candidate key lets the
1358
1489
  // terminal event report the post-approval learning progress.
1359
1490
  this.trackAsk(exec, neutralReason, learning.enabled ? key : undefined, askCtx);
1360
- return this.askDecision(askCtx, neutralReason, true);
1491
+ return this.askDecision(askCtx, neutralReason, true, note);
1361
1492
  }
1362
1493
  // unresolved: keep the ask, learn nothing — and label it. Without a marker
1363
1494
  // here, "the grader timed out" and "the grader called this risky" reach the
1364
1495
  // human as the same sentence, so an outage silently impersonates a
1365
1496
  // security judgement. Tagged via `reason` rather than a new event `kind`,
1366
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);
1367
1504
  const unresolvedReason = `${decision.reason} [llm-assist unresolved → ask] · ${humanCardLine(askCtx)}`;
1368
1505
  this.trackAsk(exec, unresolvedReason, undefined, askCtx);
1369
1506
  return this.askDecision(askCtx, unresolvedReason);
1370
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
+ }
1371
1777
  /**
1372
1778
  * One frozen view of a call, built the same way for every ask path so the
1373
1779
  * popup cannot disagree with what the grader was shown.
@@ -1384,6 +1790,7 @@ export class PermGateRuntime {
1384
1790
  ruleReason: reason,
1385
1791
  callId: exec.callId,
1386
1792
  knobs: { permissionPreset: this.presetOf(exec) },
1793
+ ...this.scriptReviewFor(exec, workspace),
1387
1794
  });
1388
1795
  }
1389
1796
  /**
@@ -1399,17 +1806,20 @@ export class PermGateRuntime {
1399
1806
  * each button costs. Without the command line a person is asked to approve an
1400
1807
  * operation they cannot read, which is the complaint this task exists to fix.
1401
1808
  */
1402
- askDecision(askCtx, reason, modelFlagged = false) {
1809
+ askDecision(askCtx, reason, modelFlagged = false, modelNote) {
1403
1810
  // `reason` stays the audit string: short, English, machine-tokened. Everything a
1404
1811
  // person reads goes into `displayReason`, which the host resolves by UI locale
1405
1812
  // (`zh` here; the other seven fall back to `en`) and never persists.
1406
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}`);
1407
1817
  return {
1408
1818
  kind: 'ask',
1409
1819
  reason,
1410
1820
  displayReason: {
1411
- zh: humanBrief(askCtx, 'zh', kinds),
1412
- en: humanBrief(askCtx, 'en', kinds),
1821
+ zh: `${humanBrief(askCtx, 'zh', kinds)}${tail('模型审查意见:')}`,
1822
+ en: `${humanBrief(askCtx, 'en', kinds)}${tail('risk model says: ')}`,
1413
1823
  },
1414
1824
  };
1415
1825
  }
@@ -1755,42 +2165,6 @@ function keywordMatcher(keyword) {
1755
2165
  keywordMatchers.set(keyword, re);
1756
2166
  return re;
1757
2167
  }
1758
- /**
1759
- * The text a blacklist keyword may match: the shell command plus the call's
1760
- * scalar arguments, excluding document-body fields. Whitespace is collapsed so
1761
- * a command written with extra spaces still reads as the command it is.
1762
- */
1763
- function denyScanText(exec) {
1764
- const parts = [];
1765
- const walk = (node, depth) => {
1766
- if (depth > 6)
1767
- return;
1768
- if (Array.isArray(node)) {
1769
- for (const item of node) {
1770
- if (typeof item === 'string')
1771
- parts.push(item);
1772
- else
1773
- walk(item, depth + 1);
1774
- }
1775
- return;
1776
- }
1777
- if (typeof node !== 'object' || node === null)
1778
- return;
1779
- for (const [key, value] of Object.entries(node)) {
1780
- if (typeof value === 'string') {
1781
- if (!CONTENT_ARG_KEYS.has(key))
1782
- parts.push(value);
1783
- }
1784
- else {
1785
- walk(value, depth + 1);
1786
- }
1787
- }
1788
- };
1789
- walk(exec.arguments ?? {}, 0);
1790
- if (typeof exec.commandText === 'string' && exec.commandText !== '')
1791
- parts.unshift(exec.commandText);
1792
- return parts.join(' ').replace(/\s+/g, ' ');
1793
- }
1794
2168
  function randomId() {
1795
2169
  return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
1796
2170
  }