@yagni-app/code 0.3.2 → 0.3.3
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/dist/cli.js +13 -0
- package/dist/extension/footer.d.ts +1 -1
- package/dist/extension/hooks.d.ts +111 -0
- package/dist/extension/hooks.js +666 -0
- package/dist/extension/index.d.ts +13 -6
- package/dist/extension/index.js +57 -7
- package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
- package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
- package/dist/extension/permission/dbReadPolicy.js +227 -0
- package/dist/extension/{execPolicy.js → permission/execPolicy.js} +41 -13
- package/dist/extension/{permission.d.ts → permission/gate.d.ts} +9 -2
- package/dist/extension/{permission.js → permission/gate.js} +103 -4
- package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +2 -2
- package/dist/extension/{guardian.js → permission/guardian.js} +1 -1
- package/dist/extension/permission/index.d.ts +14 -0
- package/dist/extension/permission/index.js +14 -0
- package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
- package/dist/extension/permission/packageManagerPolicy.js +170 -0
- package/dist/extension/pipeline/activityFeed.js +19 -5
- package/dist/extension/pipeline/checker.d.ts +99 -0
- package/dist/extension/pipeline/checker.js +238 -0
- package/dist/extension/pipeline/fanout.d.ts +116 -0
- package/dist/extension/pipeline/fanout.js +248 -0
- package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
- package/dist/extension/pipeline/fanoutBeats.js +86 -0
- package/dist/extension/pipeline/goCommand.d.ts +14 -0
- package/dist/extension/pipeline/goCommand.js +38 -1
- package/dist/extension/pipeline/headlessGo.d.ts +163 -0
- package/dist/extension/pipeline/headlessGo.js +333 -0
- package/dist/extension/pipeline/invocation.d.ts +7 -1
- package/dist/extension/pipeline/invocation.js +7 -1
- package/dist/extension/pipeline/mission.d.ts +55 -0
- package/dist/extension/pipeline/mission.js +70 -0
- package/dist/extension/pipeline/orchestrator.d.ts +48 -3
- package/dist/extension/pipeline/orchestrator.js +450 -9
- package/dist/extension/pipeline/personas.d.ts +16 -1
- package/dist/extension/pipeline/personas.js +117 -6
- package/dist/extension/pipeline/runSession.d.ts +45 -1
- package/dist/extension/pipeline/runState.d.ts +57 -12
- package/dist/extension/pipeline/runState.js +60 -18
- package/dist/extension/pipeline/runner.js +10 -1
- package/dist/extension/pipeline/stages.d.ts +84 -7
- package/dist/extension/pipeline/stages.js +166 -0
- package/dist/extension/pipeline/tierCap.d.ts +32 -0
- package/dist/extension/pipeline/tierCap.js +57 -0
- package/dist/extension/pipeline/types.d.ts +130 -1
- package/dist/extension/pipeline/types.js +17 -0
- package/dist/extension/pipeline/verify.d.ts +86 -3
- package/dist/extension/pipeline/verify.js +175 -6
- package/dist/extension/turnLog.d.ts +38 -0
- package/dist/extension/turnLog.js +93 -0
- package/dist/goHeadless.d.ts +75 -0
- package/dist/goHeadless.js +132 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +12 -0
- package/package.json +2 -2
- /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
- /package/dist/extension/{execPolicy.d.ts → permission/execPolicy.d.ts} +0 -0
|
@@ -121,8 +121,8 @@ export { makeAskYagniTool } from "./askYagniTool.js";
|
|
|
121
121
|
export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
122
122
|
export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
123
123
|
export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
|
|
124
|
-
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
|
|
125
|
-
export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./guardian.js";
|
|
124
|
+
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./permission/guardian.js";
|
|
125
|
+
export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./permission/guardian.js";
|
|
126
126
|
export type { Citation, MakeAskYagniToolOptions } from "./askYagniTool.js";
|
|
127
127
|
export { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
128
128
|
export type { MakeReviewToolOptions } from "./reviewTool.js";
|
|
@@ -138,6 +138,8 @@ export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace,
|
|
|
138
138
|
export type { RunInitPassDeps, InitPassOutcome, RunTeamSetupDeps, TeamSetupOutcome, RepoIntake, RepoIntakeFs, EngineeringDraft, DraftTeam, } from "./initPass.js";
|
|
139
139
|
export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
|
|
140
140
|
export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
|
|
141
|
+
export { loadHooksConfig, makeHookRunner, registerHooks, matchesMatcher, parsePreToolUseOutput, parsePermissionRequestOutput, parseAdditionalContext, parseCompactCancel, HOOK_SUPPORTED_EVENTS, } from "./hooks.js";
|
|
142
|
+
export type { HookEntry, HookGroup, HooksConfig, HookEventName, HookRunner, PreToolUseHookResult, PermissionRequestHookResult, RegisterHooksDeps, } from "./hooks.js";
|
|
141
143
|
export { createUltraHolder, registerUltraCommand } from "./ultra.js";
|
|
142
144
|
export type { UltraHolder } from "./ultra.js";
|
|
143
145
|
export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
|
|
@@ -147,6 +149,11 @@ export { registerGoCommand } from "./pipeline/goCommand.js";
|
|
|
147
149
|
export type { RegisterGoDeps } from "./pipeline/goCommand.js";
|
|
148
150
|
export { runPipeline } from "./pipeline/orchestrator.js";
|
|
149
151
|
export type { RunPipelineDeps } from "./pipeline/orchestrator.js";
|
|
152
|
+
export { HEADLESS_GO_EXIT, HEADLESS_GO_USAGE, parseHeadlessGoArgs, runHeadlessGo, validateHeadlessGoArgs, } from "./pipeline/headlessGo.js";
|
|
153
|
+
export type { HeadlessGoArgs, HeadlessGoDeps, HeadlessGoOutcome, HeadlessGoResult, HeadlessStageRow, } from "./pipeline/headlessGo.js";
|
|
154
|
+
export { isMissionMode, missionSeed, missionSkippedStages, normalizeMission } from "./pipeline/mission.js";
|
|
155
|
+
export type { MissionInputs } from "./pipeline/mission.js";
|
|
156
|
+
export { clampTier, parseTierCap, resolveTierCap, TIER_CAP_ENV } from "./pipeline/tierCap.js";
|
|
150
157
|
export { withResilience, classifyTransient, composeAbortSignal } from "./pipeline/resilience.js";
|
|
151
158
|
export type { ResilienceAttemptRecord, ResilienceSeams, RunStageFn } from "./pipeline/resilience.js";
|
|
152
159
|
export { DEFAULT_RESILIENCE_POLICY } from "./pipeline/types.js";
|
|
@@ -160,10 +167,10 @@ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkd
|
|
|
160
167
|
export type { SubagentDef, SubagentSource } from "./subagents.js";
|
|
161
168
|
export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
|
|
162
169
|
export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
|
|
163
|
-
export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
|
|
164
|
-
export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
|
|
165
|
-
export type { ExecDecision, ExecPolicy, PrefixRule, ExecClassification, } from "./execPolicy.js";
|
|
166
|
-
export type { PermissionMode, PermissionPolicy, GateDecision, RegisterPermissionDeps, BlessRememberInfo, } from "./permission.js";
|
|
170
|
+
export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
|
|
171
|
+
export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./permission/execPolicy.js";
|
|
172
|
+
export type { ExecDecision, ExecPolicy, PrefixRule, ExecClassification, } from "./permission/execPolicy.js";
|
|
173
|
+
export type { PermissionMode, PermissionPolicy, GateDecision, RegisterPermissionDeps, BlessRememberInfo, } from "./permission/gate.js";
|
|
167
174
|
export { makeBlessStore, blessPath } from "./bless.js";
|
|
168
175
|
export type { BlessStore, BlessRule } from "./bless.js";
|
|
169
176
|
export { bankDecision, fetchDecisions, supersedeDecision, parseDecideArgs, formatDecisionsList, shortId, registerDecisionCommands, DEFAULT_LIST_LIMIT, } from "./decisions.js";
|
package/dist/extension/index.js
CHANGED
|
@@ -3,9 +3,9 @@ import { appendFileSync, mkdirSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
5
5
|
import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
|
|
6
|
-
import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./approvedPrefixes.js";
|
|
6
|
+
import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
|
|
7
7
|
import { redactCommand } from "./redact.js";
|
|
8
|
-
import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./guardian.js";
|
|
8
|
+
import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./permission/guardian.js";
|
|
9
9
|
import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
10
10
|
import { makeAskYagniTool } from "./askYagniTool.js";
|
|
11
11
|
import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
@@ -20,6 +20,7 @@ import { registerCostCommand } from "./costHud.js";
|
|
|
20
20
|
import { isDebug } from "./diagnostics.js";
|
|
21
21
|
import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
|
|
22
22
|
import { codeStateHome } from "./stateHome.js";
|
|
23
|
+
import { logTurnLifecycle } from "./turnLog.js";
|
|
23
24
|
import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_PATTERN, isShiftTab } from "./footer.js";
|
|
24
25
|
import { RerouteNotifier } from "./rerouteNotice.js";
|
|
25
26
|
import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
|
|
@@ -27,7 +28,8 @@ import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone }
|
|
|
27
28
|
import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, registerMcpTools, } from "./mcpTools.js";
|
|
28
29
|
import { registerGoCommand } from "./pipeline/goCommand.js";
|
|
29
30
|
import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
|
|
30
|
-
import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission.js";
|
|
31
|
+
import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
|
|
32
|
+
import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
|
|
31
33
|
import { registerSubagents } from "./subagents.js";
|
|
32
34
|
import { createUltraHolder, registerUltraCommand } from "./ultra.js";
|
|
33
35
|
import { registerTodos } from "./todos.js";
|
|
@@ -139,7 +141,12 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
139
141
|
// `yagni` provider, so /model and Ctrl+P show a single entry. Child
|
|
140
142
|
// processes (/go, subagents, advisor) fetch their own catalog and register
|
|
141
143
|
// their own provider, so they are unaffected by this filter.
|
|
142
|
-
|
|
144
|
+
//
|
|
145
|
+
// Eval mode is exempt: a headless harness (scoping sessions, code evals)
|
|
146
|
+
// names its tier explicitly — the backend's scoping sessions run `--model
|
|
147
|
+
// standard` — and there is no /model picker to keep tidy. Filtering there
|
|
148
|
+
// turns a valid tier request into "no models match".
|
|
149
|
+
const catalog = evalMode ? fullCatalog : fullCatalog.filter((m) => m.id === "advanced");
|
|
143
150
|
// YAG-471: the driver's own completions carry attribution headers read from
|
|
144
151
|
// this process's env (YAGNI_SESSION_ID minted by the launcher; YAGNI_CALLER
|
|
145
152
|
// defaults to "driver" when unset, i.e. every session that is not a /go
|
|
@@ -206,6 +213,10 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
206
213
|
// The grounded multi-agent pipeline entry point: /go <ticket> runs
|
|
207
214
|
// map → plan → implement → review → fix, each child grounded by inheritance.
|
|
208
215
|
registerGoCommand(pi, {
|
|
216
|
+
// /ultra is one dial for the whole session: the same holder the subagent
|
|
217
|
+
// tool reads widens the implement diamond's parallel ceiling (4 -> 8) for
|
|
218
|
+
// the fan and its fix turns. Read per run, so a toggle lands on the next /go.
|
|
219
|
+
isUltra: () => ultraHolder.get(),
|
|
209
220
|
// Task 8: /go's end-of-run summary prefers the server-priced per-stage
|
|
210
221
|
// breakdown for the ONE run that just finished (`?runId=`, aliasing the
|
|
211
222
|
// spend endpoint's `sessionId` param — see the backend route), over
|
|
@@ -280,6 +291,12 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
280
291
|
// rejected as a same-session self-authorization path, PR #1698).
|
|
281
292
|
const sessionGrants = evalMode ? [] : loadGrants();
|
|
282
293
|
const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
|
|
294
|
+
// YAG-506: load user-configurable lifecycle hooks config and create the
|
|
295
|
+
// hook runner for the permission gate. Skipped in eval mode.
|
|
296
|
+
const hooksConfig = evalMode ? {} : loadHooksConfig();
|
|
297
|
+
const hookRunner = evalMode ? null : makeHookRunner({ config: hooksConfig });
|
|
298
|
+
if (!evalMode)
|
|
299
|
+
registerHooks(pi, { config: hooksConfig });
|
|
283
300
|
registerPermissionGate(pi, {
|
|
284
301
|
modeHolder,
|
|
285
302
|
guardianState,
|
|
@@ -362,6 +379,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
362
379
|
onBlessRemember: decisionCapture
|
|
363
380
|
? (ctx, info) => decisionCapture.captureFromBless(ctx, info)
|
|
364
381
|
: undefined,
|
|
382
|
+
...(hookRunner ? { hookRunner } : {}),
|
|
365
383
|
});
|
|
366
384
|
// P2/YAG-383: /cost prefers the server-authoritative session spend (covers
|
|
367
385
|
// subagents and advisor consults directly, since they bill under this same
|
|
@@ -564,6 +582,28 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
564
582
|
rulesSection,
|
|
565
583
|
}),
|
|
566
584
|
});
|
|
585
|
+
// Turn-lifecycle WAL: a `turn_start` with no matching `turn_end` is the
|
|
586
|
+
// silent-freeze tell (the turn that left the UI stuck on "Working…"), even
|
|
587
|
+
// if the process is later killed. `turn_end` records the final stopReason so
|
|
588
|
+
// an `error` / `aborted` end is visible at a glance without reading the raw
|
|
589
|
+
// session .jsonl. Fail-soft; nothing here may break a turn.
|
|
590
|
+
const turnStartMs = new Map();
|
|
591
|
+
const sessionIdForLog = () => deps.env?.YAGNI_SESSION_ID;
|
|
592
|
+
pi.on("turn_start", (event) => {
|
|
593
|
+
turnStartMs.set(event.turnIndex, Date.now());
|
|
594
|
+
logTurnLifecycle({ kind: "turn_start", sessionId: sessionIdForLog(), turnIndex: event.turnIndex });
|
|
595
|
+
});
|
|
596
|
+
pi.on("turn_end", (event) => {
|
|
597
|
+
const startedAt = turnStartMs.get(event.turnIndex);
|
|
598
|
+
turnStartMs.delete(event.turnIndex);
|
|
599
|
+
logTurnLifecycle({
|
|
600
|
+
kind: "turn_end",
|
|
601
|
+
sessionId: sessionIdForLog(),
|
|
602
|
+
turnIndex: event.turnIndex,
|
|
603
|
+
stopReason: event.message?.stopReason,
|
|
604
|
+
elapsedMs: startedAt !== undefined ? Date.now() - startedAt : undefined,
|
|
605
|
+
});
|
|
606
|
+
});
|
|
567
607
|
// Two finalized-message guards share this handler (their conditions are
|
|
568
608
|
// mutually exclusive: YAG-460 takes error-stopped messages, YAG-466 takes
|
|
569
609
|
// stop/length ones without an errorMessage).
|
|
@@ -847,7 +887,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
|
|
|
847
887
|
export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
848
888
|
export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
849
889
|
export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
|
|
850
|
-
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
|
|
890
|
+
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./permission/guardian.js";
|
|
851
891
|
export { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
852
892
|
export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
|
|
853
893
|
export { makeRecordDecisionTool } from "./recordDecisionTool.js";
|
|
@@ -859,12 +899,22 @@ export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace,
|
|
|
859
899
|
// Onramp Door B (F2a): the one-time init-pass idempotency marker.
|
|
860
900
|
export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
|
|
861
901
|
export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
|
|
902
|
+
// YAG-506: user-configurable lifecycle hooks.
|
|
903
|
+
export { loadHooksConfig, makeHookRunner, registerHooks, matchesMatcher, parsePreToolUseOutput, parsePermissionRequestOutput, parseAdditionalContext, parseCompactCancel, HOOK_SUPPORTED_EVENTS, } from "./hooks.js";
|
|
862
904
|
// Ultra mode (/ultra): the aggressive fan-out/verify/synthesize dial.
|
|
863
905
|
export { createUltraHolder, registerUltraCommand } from "./ultra.js";
|
|
864
906
|
export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
|
|
865
907
|
export { buildYagniProvider } from "./provider.js";
|
|
866
908
|
export { registerGoCommand } from "./pipeline/goCommand.js";
|
|
867
909
|
export { runPipeline } from "./pipeline/orchestrator.js";
|
|
910
|
+
// The headless front door (`yagni go --headless`): the same pipeline, driven by
|
|
911
|
+
// a script or the mission sandbox instead of a session.
|
|
912
|
+
export { HEADLESS_GO_EXIT, HEADLESS_GO_USAGE, parseHeadlessGoArgs, runHeadlessGo, validateHeadlessGoArgs, } from "./pipeline/headlessGo.js";
|
|
913
|
+
// Mission mode: the pure rules for entering the pipeline on an already-approved
|
|
914
|
+
// plan (map/plan skipped, memo as the stand-in repo brief, FINISH not ours).
|
|
915
|
+
export { isMissionMode, missionSeed, missionSkippedStages, normalizeMission } from "./pipeline/mission.js";
|
|
916
|
+
// The eval/smoke tier ceiling (YAGNI_GO_TIER_CAP), clamped centrally in runStage.
|
|
917
|
+
export { clampTier, parseTierCap, resolveTierCap, TIER_CAP_ENV } from "./pipeline/tierCap.js";
|
|
868
918
|
// R1: the in-loop resilience HOF a dev can compose with (or replace at) the
|
|
869
919
|
// `runStage` seam, plus its policy type and the default policy.
|
|
870
920
|
export { withResilience, classifyTransient, composeAbortSignal } from "./pipeline/resilience.js";
|
|
@@ -880,8 +930,8 @@ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkd
|
|
|
880
930
|
export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
|
|
881
931
|
// P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
|
|
882
932
|
// the session bless-with-remember capture hook.
|
|
883
|
-
export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
|
|
884
|
-
export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
|
|
933
|
+
export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
|
|
934
|
+
export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./permission/execPolicy.js";
|
|
885
935
|
// W4 judgment loop: the session bless store (tool + path-prefix, session-only).
|
|
886
936
|
export { makeBlessStore, blessPath } from "./bless.js";
|
|
887
937
|
// W4 judgment loop: the decisions surface (/decide + /decisions) + shared bank.
|
|
@@ -29,7 +29,7 @@ import { execFileSync } from "node:child_process";
|
|
|
29
29
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
import { classifyCommand, shellParse, tokenize } from "./execPolicy.js";
|
|
32
|
-
import { codeStateHome } from "
|
|
32
|
+
import { codeStateHome } from "../stateHome.js";
|
|
33
33
|
// --- Derivation ---
|
|
34
34
|
/** Tools whose second token is a subcommand worth capturing in a prefix. */
|
|
35
35
|
const MULTI_SUBCOMMAND_TOOLS = new Set([
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database read auto-allow policy (YAG-561).
|
|
3
|
+
*
|
|
4
|
+
* Auto-allows *provably read-only* inline SQL for a small set of database
|
|
5
|
+
* clients, so routine SELECTs never reach the Guardian. Anything we cannot
|
|
6
|
+
* prove read-only is left ENTIRELY to the Guardian — this module has no
|
|
7
|
+
* "deny" outcome (see the invariant below).
|
|
8
|
+
*
|
|
9
|
+
* The hard, easy part is extracting the SQL safely WITHOUT regex over the raw
|
|
10
|
+
* command. We reuse execPolicy's shellParse — the same quote-aware tokenizer
|
|
11
|
+
* that powers the whole classifier — so `-c "SELECT 1; SELECT 2"` arrives as
|
|
12
|
+
* one clean, dequoted token and a `;` OUTSIDE quotes still reads as a real
|
|
13
|
+
* command separator. Extraction failures (unknown flag, unknown short-cluster,
|
|
14
|
+
* `-f file`, stdin `<`) simply left as `readonly: false`.
|
|
15
|
+
*
|
|
16
|
+
* ── The single invariant ────────────────────────────────────────────────
|
|
17
|
+
* classifySql returns "read" ONLY when the SQL is provably read-only. A
|
|
18
|
+
* false "read" auto-runs a write across every workspace's DB, which is
|
|
19
|
+
* unacceptable; a false "unknown" costs one Guardian consult, which is free.
|
|
20
|
+
* We therefore over-reject: a write marker, a mutating function name, or a
|
|
21
|
+
* `\` meta-command ANYWHERE in the SQL downgrades the whole thing to
|
|
22
|
+
* "unknown". No attempt is made to distinguish "called" from "mentioned in
|
|
23
|
+
* a string literal" — erring toward prompt is always the safe direction.
|
|
24
|
+
*
|
|
25
|
+
* NOTE: a non-"read" result is NOT "forbidden". An UPDATE/INSERT/DELETE is
|
|
26
|
+
* sent to the Guardian where a human/LLM judges intent. This module never
|
|
27
|
+
* flat-denies a DB write.
|
|
28
|
+
*
|
|
29
|
+
* ── How to extend ────────────────────────────────────────────────────────
|
|
30
|
+
* Add a new client: push a DbClientSpec to DB_CLIENTS.
|
|
31
|
+
* Add a new mutating function: append to the relevant client's
|
|
32
|
+
* mutatingFunctions list (schema-prefix-agnostic word-boundary match).
|
|
33
|
+
* Add a new way to pass SQL: add a SqlArgExtractor to the client.
|
|
34
|
+
* Every change needs a paired allow + regression test in dbReadPolicy.test.ts.
|
|
35
|
+
* See AGENTS.md alongside this directory for the full recipe.
|
|
36
|
+
*/
|
|
37
|
+
/** A single argv shape that carries inline SQL (e.g. psql `-c "…"`). */
|
|
38
|
+
export interface SqlArgExtractor {
|
|
39
|
+
/**
|
|
40
|
+
* Token forms introducing SQL: `-c`, `--command`, `-e`, `--execute`, … The
|
|
41
|
+
* extractor accepts these in three forms: glued long (`--command=…`), glued
|
|
42
|
+
* short (`-c…` for the 2-char flags only), and bare (`-c` → SQL is the next
|
|
43
|
+
* token). A bare single-dash flag may also match a short cluster
|
|
44
|
+
* (`-Atc`) when its terminal letter is `shortCluster.terminal` and every
|
|
45
|
+
* preceding letter is in `shortCluster.allowedPrefixes`.
|
|
46
|
+
*/
|
|
47
|
+
flags: readonly string[];
|
|
48
|
+
shortCluster?: {
|
|
49
|
+
terminal: string;
|
|
50
|
+
allowedPrefixes: readonly string[];
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Everything we know about one database client. */
|
|
54
|
+
export interface DbClientSpec {
|
|
55
|
+
binaries: readonly string[];
|
|
56
|
+
sqlArgExtractors: readonly SqlArgExtractor[];
|
|
57
|
+
/** Is this (dequoted, full) SQL provably read-only? */
|
|
58
|
+
classifySql: (sql: string) => boolean;
|
|
59
|
+
/** Function names that make a bare SELECT mutate/abuse — reject as unknown. */
|
|
60
|
+
mutatingFunctions: readonly string[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The shared "provably read-only" gate. Returns true only when every
|
|
64
|
+
* statement in `sql` (after comment-stripping) begins with SELECT/WITH and
|
|
65
|
+
* contains no write verb, write clause, mutating function, or backslash
|
|
66
|
+
* meta-command.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isSqlReadOnly(sql: string, mutatingFunctions: readonly string[]): boolean;
|
|
69
|
+
/** The supported database clients. Add a new one here to expand coverage. */
|
|
70
|
+
export declare const DB_CLIENTS: DbClientSpec[];
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the SQL string for a known client binary from its tokenized argv.
|
|
73
|
+
* Returns `{ sql }` on success (the dequoted SQL as one string) or null when
|
|
74
|
+
* the argv shape can't be proven to carry inline SQL. Null → left to Guardian.
|
|
75
|
+
*
|
|
76
|
+
* Supported token shapes, in the order tried:
|
|
77
|
+
* 1. glued long flag `--command=SELECT …` / `--execute=SELECT …`
|
|
78
|
+
* 2. glued short flag `-cSELECT …` / `-eSELECT …` (2-char flag + payload)
|
|
79
|
+
* 3. bare flag `-c` / `--command` / `-Atc` → SQL is the NEXT token
|
|
80
|
+
*/
|
|
81
|
+
export declare function extractSqlArg(tokens: readonly string[], spec: DbClientSpec): {
|
|
82
|
+
sql: string;
|
|
83
|
+
} | null;
|
|
84
|
+
/**
|
|
85
|
+
* Top-level: should this database command be auto-allowed? Consumes the raw
|
|
86
|
+
* tokenized argv (tokens[0] is the binary) and returns true ONLY when the
|
|
87
|
+
* inline SQL was extracted AND classified read-only.
|
|
88
|
+
*/
|
|
89
|
+
export declare function classifyDbRead(tokens: readonly string[]): boolean;
|
|
90
|
+
//# sourceMappingURL=dbReadPolicy.d.ts.map
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database read auto-allow policy (YAG-561).
|
|
3
|
+
*
|
|
4
|
+
* Auto-allows *provably read-only* inline SQL for a small set of database
|
|
5
|
+
* clients, so routine SELECTs never reach the Guardian. Anything we cannot
|
|
6
|
+
* prove read-only is left ENTIRELY to the Guardian — this module has no
|
|
7
|
+
* "deny" outcome (see the invariant below).
|
|
8
|
+
*
|
|
9
|
+
* The hard, easy part is extracting the SQL safely WITHOUT regex over the raw
|
|
10
|
+
* command. We reuse execPolicy's shellParse — the same quote-aware tokenizer
|
|
11
|
+
* that powers the whole classifier — so `-c "SELECT 1; SELECT 2"` arrives as
|
|
12
|
+
* one clean, dequoted token and a `;` OUTSIDE quotes still reads as a real
|
|
13
|
+
* command separator. Extraction failures (unknown flag, unknown short-cluster,
|
|
14
|
+
* `-f file`, stdin `<`) simply left as `readonly: false`.
|
|
15
|
+
*
|
|
16
|
+
* ── The single invariant ────────────────────────────────────────────────
|
|
17
|
+
* classifySql returns "read" ONLY when the SQL is provably read-only. A
|
|
18
|
+
* false "read" auto-runs a write across every workspace's DB, which is
|
|
19
|
+
* unacceptable; a false "unknown" costs one Guardian consult, which is free.
|
|
20
|
+
* We therefore over-reject: a write marker, a mutating function name, or a
|
|
21
|
+
* `\` meta-command ANYWHERE in the SQL downgrades the whole thing to
|
|
22
|
+
* "unknown". No attempt is made to distinguish "called" from "mentioned in
|
|
23
|
+
* a string literal" — erring toward prompt is always the safe direction.
|
|
24
|
+
*
|
|
25
|
+
* NOTE: a non-"read" result is NOT "forbidden". An UPDATE/INSERT/DELETE is
|
|
26
|
+
* sent to the Guardian where a human/LLM judges intent. This module never
|
|
27
|
+
* flat-denies a DB write.
|
|
28
|
+
*
|
|
29
|
+
* ── How to extend ────────────────────────────────────────────────────────
|
|
30
|
+
* Add a new client: push a DbClientSpec to DB_CLIENTS.
|
|
31
|
+
* Add a new mutating function: append to the relevant client's
|
|
32
|
+
* mutatingFunctions list (schema-prefix-agnostic word-boundary match).
|
|
33
|
+
* Add a new way to pass SQL: add a SqlArgExtractor to the client.
|
|
34
|
+
* Every change needs a paired allow + regression test in dbReadPolicy.test.ts.
|
|
35
|
+
* See AGENTS.md alongside this directory for the full recipe.
|
|
36
|
+
*/
|
|
37
|
+
// --- Read-only classifier helpers ---
|
|
38
|
+
/** Strip leading/leading whitespace and `/* … */` block + `-- …` line comments. */
|
|
39
|
+
function stripSqlComments(sql) {
|
|
40
|
+
let out = sql;
|
|
41
|
+
// Block comments (non-greedy, across newlines).
|
|
42
|
+
out = out.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
43
|
+
// Line comments to end-of-line.
|
|
44
|
+
out = out.replace(/--[^\n]*/g, " ");
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** Write verbs / statements that are never read-only. */
|
|
48
|
+
const WRITE_VERBS = /\b(insert|update|delete|truncate|drop|create|alter|grant|revoke|copy|vacuum|merge|call|set|replace|load)\b/i;
|
|
49
|
+
/** Statements that use SELECT but still write (INTO, FOR UPDATE, …). */
|
|
50
|
+
const WRITE_CLAUSES = /\b(into\s+(outfile|dumpfile|new\s+table|table)|for\s+(update|share)|returning)\b/i;
|
|
51
|
+
/** Match a function name with a word boundary (catches `schema.fn` and `fn(`). */
|
|
52
|
+
function mentionsFunction(sql, fn) {
|
|
53
|
+
const re = new RegExp(`(^|[^\\w])${fn}\\s*\\(`, "i");
|
|
54
|
+
return re.test(sql);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The shared "provably read-only" gate. Returns true only when every
|
|
58
|
+
* statement in `sql` (after comment-stripping) begins with SELECT/WITH and
|
|
59
|
+
* contains no write verb, write clause, mutating function, or backslash
|
|
60
|
+
* meta-command.
|
|
61
|
+
*/
|
|
62
|
+
export function isSqlReadOnly(sql, mutatingFunctions) {
|
|
63
|
+
const stripped = stripSqlComments(sql).trim();
|
|
64
|
+
// Any backslash is a psql/mysql meta-command (`\copy`, `\!`, `\i`) — not a
|
|
65
|
+
// plain SQL statement. Never auto-allow.
|
|
66
|
+
if (stripped.includes("\\"))
|
|
67
|
+
return false;
|
|
68
|
+
// A mutating function ANYWHERE (schema-prefixed, string-literal mention, or
|
|
69
|
+
// genuine call) downgrades the whole thing. Over-reject on purpose.
|
|
70
|
+
if (mutatingFunctions.some((fn) => mentionsFunction(stripped, fn)))
|
|
71
|
+
return false;
|
|
72
|
+
// Write verbs / clauses anywhere → not read-only. (Single pass over the whole
|
|
73
|
+
// multi-statement string means any `;`-joined write poisons the read.)
|
|
74
|
+
if (WRITE_VERBS.test(stripped))
|
|
75
|
+
return false;
|
|
76
|
+
if (WRITE_CLAUSES.test(stripped))
|
|
77
|
+
return false;
|
|
78
|
+
// The first statement must open with SELECT or WITH. (A leading `WITH`
|
|
79
|
+
// CTE is safe, but `WITH` can also be INSERT … WITH — the WRITE_VERBS
|
|
80
|
+
// check already caught any write verb, so a remaining `WITH` lead is a CTE.)
|
|
81
|
+
if (!/^(select|with)\b/i.test(stripped))
|
|
82
|
+
return false;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
// --- Client registry ---
|
|
86
|
+
/**
|
|
87
|
+
* PostgreSQL. Inline SQL via `-c` / `--command` (glued or `=`) and the
|
|
88
|
+
* `-Atc`-style short cluster. Mutating built-ins that a plain SELECT can
|
|
89
|
+
* smuggle are rejected as unknown.
|
|
90
|
+
*/
|
|
91
|
+
const postgresMutatingFunctions = [
|
|
92
|
+
// sequence / identity
|
|
93
|
+
"nextval", "setval",
|
|
94
|
+
// large objects (server filesystem writes)
|
|
95
|
+
"lo_import", "lo_export", "lo_creat", "lo_create", "lo_unlink", "lo_put",
|
|
96
|
+
// server admin / signaling
|
|
97
|
+
"pg_terminate_backend", "pg_cancel_backend", "pg_reload_conf",
|
|
98
|
+
"pg_rotate_logfile", "pg_promote", "pg_log_backend_memory_contexts",
|
|
99
|
+
// advisory locks / notification / snapshots (state-changing)
|
|
100
|
+
"pg_advisory_lock", "pg_advisory_lock_shared", "pg_advisory_xact_lock",
|
|
101
|
+
"pg_advisory_xact_lock_shared", "pg_try_advisory_lock",
|
|
102
|
+
"pg_try_advisory_lock_shared", "pg_try_advisory_xact_lock",
|
|
103
|
+
"pg_try_advisory_xact_lock_shared", "pg_advisory_unlock",
|
|
104
|
+
"pg_advisory_unlock_shared", "pg_advisory_unlock_all", "pg_notify",
|
|
105
|
+
"pg_export_snapshot",
|
|
106
|
+
// replication slots
|
|
107
|
+
"pg_create_logical_replication_slot", "pg_drop_replication_slot",
|
|
108
|
+
"pg_create_physical_replication_slot",
|
|
109
|
+
// superuser server-side filesystem reads (exfil surface)
|
|
110
|
+
"pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file",
|
|
111
|
+
// resource abuse
|
|
112
|
+
"pg_sleep", "pg_sleep_for", "pg_sleep_until",
|
|
113
|
+
// remote execution via dblink
|
|
114
|
+
"dblink", "dblink_exec", "dblink_connect", "dblink_disconnect",
|
|
115
|
+
"dblink_send_query", "dblink_get_result", "dblink_open", "dblink_fetch",
|
|
116
|
+
];
|
|
117
|
+
const mysqlMutatingFunctions = [
|
|
118
|
+
"sleep", "benchmark", "load_file",
|
|
119
|
+
"get_lock", "release_lock", "release_all_locks",
|
|
120
|
+
];
|
|
121
|
+
/** The supported database clients. Add a new one here to expand coverage. */
|
|
122
|
+
export const DB_CLIENTS = [
|
|
123
|
+
{
|
|
124
|
+
binaries: ["psql"],
|
|
125
|
+
sqlArgExtractors: [
|
|
126
|
+
{
|
|
127
|
+
flags: ["-c", "--command"],
|
|
128
|
+
shortCluster: {
|
|
129
|
+
terminal: "c",
|
|
130
|
+
allowedPrefixes: ["A", "t", "P", "q", "X", "a", "b", "e", "E", "f", "h", "n", "o", "p", "s", "S", "T", "U", "v", "V", "w", "W", "x", "z"],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
classifySql: (sql) => isSqlReadOnly(sql, postgresMutatingFunctions),
|
|
135
|
+
mutatingFunctions: postgresMutatingFunctions,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
binaries: ["mysql", "mariadb"],
|
|
139
|
+
sqlArgExtractors: [
|
|
140
|
+
{ flags: ["-e", "--execute"] },
|
|
141
|
+
],
|
|
142
|
+
classifySql: (sql) => isSqlReadOnly(sql, mysqlMutatingFunctions),
|
|
143
|
+
mutatingFunctions: mysqlMutatingFunctions,
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
// --- SQL extraction (reuses shellParse — no regex over the raw command) ---
|
|
147
|
+
/**
|
|
148
|
+
* Does the flag (exact equality) match a token, including a single-dash
|
|
149
|
+
* short-cluster form like `-Atc` (terminal flag letter + an allowlisted set of
|
|
150
|
+
* prefix letters)? Only used when the token has no glued SQL payload.
|
|
151
|
+
*/
|
|
152
|
+
function matchesFlagExactly(token, extractor) {
|
|
153
|
+
if (extractor.flags.includes(token))
|
|
154
|
+
return true;
|
|
155
|
+
if (extractor.shortCluster && token.startsWith("-") && !token.startsWith("--")) {
|
|
156
|
+
const { terminal, allowedPrefixes } = extractor.shortCluster;
|
|
157
|
+
const body = token.slice(1); // drop the leading '-'
|
|
158
|
+
if (!body.endsWith(terminal) || body.length < 2)
|
|
159
|
+
return false;
|
|
160
|
+
const prefixes = body.slice(0, -1).split("");
|
|
161
|
+
return prefixes.every((p) => allowedPrefixes.includes(p));
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the SQL string for a known client binary from its tokenized argv.
|
|
167
|
+
* Returns `{ sql }` on success (the dequoted SQL as one string) or null when
|
|
168
|
+
* the argv shape can't be proven to carry inline SQL. Null → left to Guardian.
|
|
169
|
+
*
|
|
170
|
+
* Supported token shapes, in the order tried:
|
|
171
|
+
* 1. glued long flag `--command=SELECT …` / `--execute=SELECT …`
|
|
172
|
+
* 2. glued short flag `-cSELECT …` / `-eSELECT …` (2-char flag + payload)
|
|
173
|
+
* 3. bare flag `-c` / `--command` / `-Atc` → SQL is the NEXT token
|
|
174
|
+
*/
|
|
175
|
+
export function extractSqlArg(tokens, spec) {
|
|
176
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
177
|
+
const tok = tokens[i];
|
|
178
|
+
for (const extractor of spec.sqlArgExtractors) {
|
|
179
|
+
// 1. glued long flag: `--command=SELECT …`
|
|
180
|
+
for (const f of extractor.flags) {
|
|
181
|
+
if (f.startsWith("--") && tok.startsWith(f + "=")) {
|
|
182
|
+
const sql = tok.slice(f.length + 1);
|
|
183
|
+
if (sql.length > 0)
|
|
184
|
+
return { sql };
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// 2. glued short flag: `-cSELECT …` (only for bare 2-char flags in the
|
|
189
|
+
// extractor, not short-clusters — a cluster like `-Atc` is a pure
|
|
190
|
+
// flag, not glue).
|
|
191
|
+
for (const f of extractor.flags) {
|
|
192
|
+
if (f.length === 2 && f.startsWith("-") && !f.startsWith("--")) {
|
|
193
|
+
if (tok.startsWith(f) && tok.length > 2) {
|
|
194
|
+
const sql = tok.slice(2);
|
|
195
|
+
if (sql.length > 0)
|
|
196
|
+
return { sql };
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// 3. bare flag → SQL is the next token.
|
|
202
|
+
if (matchesFlagExactly(tok, extractor)) {
|
|
203
|
+
const v = tokens[i + 1];
|
|
204
|
+
if (typeof v === "string" && v.length > 0)
|
|
205
|
+
return { sql: v };
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Top-level: should this database command be auto-allowed? Consumes the raw
|
|
214
|
+
* tokenized argv (tokens[0] is the binary) and returns true ONLY when the
|
|
215
|
+
* inline SQL was extracted AND classified read-only.
|
|
216
|
+
*/
|
|
217
|
+
export function classifyDbRead(tokens) {
|
|
218
|
+
const binary = tokens[0];
|
|
219
|
+
const spec = DB_CLIENTS.find((c) => c.binaries.includes(binary));
|
|
220
|
+
if (!spec)
|
|
221
|
+
return false;
|
|
222
|
+
const extracted = extractSqlArg(tokens, spec);
|
|
223
|
+
if (!extracted)
|
|
224
|
+
return false;
|
|
225
|
+
return spec.classifySql(extracted.sql);
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=dbReadPolicy.js.map
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
* extension is bundled into @yagni-app/code's dist (a file copy, not a real
|
|
35
35
|
* bundler), and external dependencies aren't resolvable from the bundled path.
|
|
36
36
|
*/
|
|
37
|
+
import { classifyDbRead } from "./dbReadPolicy.js";
|
|
38
|
+
import { forwarderLabel, forwarderTailStart, normalizePackageManagerTokens, PACKAGE_MANAGER_ALLOW_RULES, } from "./packageManagerPolicy.js";
|
|
37
39
|
/**
|
|
38
40
|
* Parse a shell command string into tokens and control operators.
|
|
39
41
|
*
|
|
@@ -574,11 +576,13 @@ function matchRule(tokens, rule) {
|
|
|
574
576
|
const pat = rule.pattern[i];
|
|
575
577
|
const tok = tokens[i];
|
|
576
578
|
if (typeof pat === "string") {
|
|
577
|
-
|
|
579
|
+
// A trailing "*" glob lets a single pattern cover a namespace of tokens
|
|
580
|
+
// (e.g. "test:*" matches "test:backend", "test:file", …).
|
|
581
|
+
if (!tokenMatchesEntry(tok, pat))
|
|
578
582
|
return false;
|
|
579
583
|
}
|
|
580
584
|
else {
|
|
581
|
-
if (!pat.
|
|
585
|
+
if (!pat.some((entry) => tokenMatchesEntry(tok, entry)))
|
|
582
586
|
return false;
|
|
583
587
|
}
|
|
584
588
|
}
|
|
@@ -633,11 +637,28 @@ function classifySegmentTokens(rawTokens, policy, opts) {
|
|
|
633
637
|
const pathPrefixed = normalizedWord !== cmdWord;
|
|
634
638
|
let tokens = pathPrefixed ? [normalizedWord, ...strippedTokens.slice(1)] : strippedTokens;
|
|
635
639
|
tokens = normalizeGitTokens(tokens);
|
|
640
|
+
tokens = normalizePackageManagerTokens(tokens);
|
|
636
641
|
const neverAllow = stripped || pathPrefixed;
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
642
|
+
// Database read promotion: a known client (psql / mysql / …) whose inline
|
|
643
|
+
// SQL is provably read-only is auto-allowed BEFORE rule matching. A
|
|
644
|
+
// non-read (write, unknown shape, -f file) result means classifyDbRead
|
|
645
|
+
// returns false and we fall straight through to the prompt-band `psql`/
|
|
646
|
+
// `mysql` rule — never to a forbidden outcome. Only consulted when the
|
|
647
|
+
// command is not already disqualified (neverAllow) and we are not in the
|
|
648
|
+
// forbidden-only danger scan.
|
|
649
|
+
if (!neverAllow && !opts.forbiddenOnly && classifyDbRead(tokens)) {
|
|
650
|
+
return {
|
|
651
|
+
decision: "allow",
|
|
652
|
+
justification: "database read-only query (SELECT/WITH, no write or mutation)",
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
// Forwarders (xargs / npx / <mgr> exec / <mgr> dlx) run their argv tail:
|
|
656
|
+
// classify the tail as its own segment so `pnpm exec rm -rf` inherits rm's
|
|
657
|
+
// forbidden floor and `npx tsc --noEmit` inherits tsc's allow. The forwarder
|
|
658
|
+
// itself is never allow; an unknown tail stays in the prompt band.
|
|
659
|
+
const tailStart = forwarderTailStart(tokens);
|
|
660
|
+
if (tailStart !== null && opts.depth < MAX_SCAN_DEPTH) {
|
|
661
|
+
let j = tailStart;
|
|
641
662
|
while (j < tokens.length && tokens[j].startsWith("-"))
|
|
642
663
|
j++;
|
|
643
664
|
const tail = tokens.slice(j);
|
|
@@ -646,12 +667,18 @@ function classifySegmentTokens(rawTokens, policy, opts) {
|
|
|
646
667
|
if (tailResult.decision === "forbidden")
|
|
647
668
|
return tailResult;
|
|
648
669
|
if (tailResult.decision === "allow" && !neverAllow) {
|
|
649
|
-
return {
|
|
670
|
+
return {
|
|
671
|
+
decision: "allow",
|
|
672
|
+
justification: `${forwarderLabel(tokens)} forwards to a read-only command`,
|
|
673
|
+
};
|
|
650
674
|
}
|
|
651
675
|
}
|
|
652
676
|
if (opts.forbiddenOnly)
|
|
653
677
|
return { decision: "allow", justification: "no forbidden match" };
|
|
654
|
-
return {
|
|
678
|
+
return {
|
|
679
|
+
decision: "prompt",
|
|
680
|
+
justification: `${forwarderLabel(tokens)} executes its argument command — review the target`,
|
|
681
|
+
};
|
|
655
682
|
}
|
|
656
683
|
// First match wins (rules are ordered; more specific rules come first).
|
|
657
684
|
for (const rule of policy.rules) {
|
|
@@ -949,11 +976,12 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
949
976
|
{ pattern: ["pnpm", "lint"], decision: "allow", justification: "run linter (routine dev-loop operation)" },
|
|
950
977
|
{ pattern: ["npm", "test"], decision: "allow", justification: "run tests (routine dev-loop operation)" },
|
|
951
978
|
{ pattern: ["npm", "run", "lint"], decision: "allow", justification: "run linter (routine dev-loop operation)" },
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
979
|
+
// package-manager dev-loop band: dev-loop binaries (tsc --noEmit, tsx
|
|
980
|
+
// --test, vitest, jest) plus the test:/build:/lint: script namespaces.
|
|
981
|
+
// The npx/<mgr> exec/<mgr> dlx spellings reach these through tail
|
|
982
|
+
// forwarding (see classifySegmentTokens), so there is no separate
|
|
983
|
+
// "npx tsc" rule — `npx tsc --noEmit` forwards to the bare tsc rule.
|
|
984
|
+
...PACKAGE_MANAGER_ALLOW_RULES,
|
|
957
985
|
// misc read-only commands
|
|
958
986
|
{ pattern: ["printenv"], decision: "allow", justification: "print environment variables (read-only)" },
|
|
959
987
|
{ pattern: ["npm", ["view", "info"]], decision: "allow", justification: "read package metadata from registry" },
|