@yagni-app/code 0.3.2 → 0.3.4
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/config.d.ts +8 -0
- package/dist/extension/config.js +6 -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 +68 -8
- 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} +22 -3
- package/dist/extension/{guardian.js → permission/guardian.js} +42 -6
- 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
|
@@ -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" },
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
30
30
|
import { type ApprovedPrefixGrant } from "./approvedPrefixes.js";
|
|
31
|
-
import {
|
|
31
|
+
import type { HookRunner } from "../hooks.js";
|
|
32
|
+
import { type BlessStore } from "../bless.js";
|
|
32
33
|
import { type ExecPolicy } from "./execPolicy.js";
|
|
33
34
|
import { type GuardianError, type GuardianRiskLevel } from "./guardian.js";
|
|
34
35
|
export type PermissionMode = "auto" | "plan" | "review";
|
|
@@ -179,6 +180,12 @@ export interface RegisterPermissionDeps {
|
|
|
179
180
|
* Fail-soft; never blocks.
|
|
180
181
|
*/
|
|
181
182
|
onGuardianEvent?: (event: GuardianGateEvent) => void;
|
|
183
|
+
/**
|
|
184
|
+
* User-configurable lifecycle hooks (YAG-506). When present, PreToolUse
|
|
185
|
+
* hooks run before decideGate and can short-circuit (allow/deny/ask),
|
|
186
|
+
* and PermissionRequest hooks run before the confirm dialog.
|
|
187
|
+
*/
|
|
188
|
+
hookRunner?: HookRunner;
|
|
182
189
|
}
|
|
183
190
|
/** The customType tag on injected mode-context messages (filterable later). */
|
|
184
191
|
export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";
|
|
@@ -202,4 +209,4 @@ export declare const filterStalePlanContext: typeof filterStaleModeContext;
|
|
|
202
209
|
* auto, so absent any /mode this is a no-op over today's behavior.
|
|
203
210
|
*/
|
|
204
211
|
export declare function registerPermissionGate(pi: ExtensionAPI, deps?: RegisterPermissionDeps): void;
|
|
205
|
-
//# sourceMappingURL=
|
|
212
|
+
//# sourceMappingURL=gate.d.ts.map
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
* the context so the model doesn't keep believing it is restricted.
|
|
28
28
|
*/
|
|
29
29
|
import { describePrefix, matchesGrant, validateGrant, } from "./approvedPrefixes.js";
|
|
30
|
-
import { makeBlessStore as defaultMakeBlessStore } from "
|
|
30
|
+
import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
|
|
31
31
|
import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
|
|
32
|
-
import { isDebug } from "
|
|
32
|
+
import { isDebug } from "../diagnostics.js";
|
|
33
33
|
import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
|
|
34
34
|
export function createModeHolder(initial = "auto") {
|
|
35
35
|
let current = initial;
|
|
@@ -296,6 +296,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
296
296
|
const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
|
|
297
297
|
let mode = deps.mode ?? "auto";
|
|
298
298
|
const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
|
|
299
|
+
const hookRunner = deps.hookRunner;
|
|
299
300
|
deps.modeHolder?.onSet((m) => {
|
|
300
301
|
if (m !== mode)
|
|
301
302
|
approvedCommands.clear();
|
|
@@ -422,7 +423,48 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
422
423
|
const modeAtEntry = mode;
|
|
423
424
|
try {
|
|
424
425
|
const input = event.input ?? {};
|
|
425
|
-
|
|
426
|
+
// YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
|
|
427
|
+
// (allow/deny/ask) or fall through to the normal gate logic. The result
|
|
428
|
+
// is cached in preToolUseResult so the "ask" check below does NOT
|
|
429
|
+
// re-invoke the hook (hooks have side effects — notifications etc.).
|
|
430
|
+
let preToolUseResult;
|
|
431
|
+
if (hookRunner) {
|
|
432
|
+
const cwd = ctx?.cwd ?? ".";
|
|
433
|
+
try {
|
|
434
|
+
preToolUseResult = await hookRunner.preToolUse(event.toolName, input, cwd, ctx?.isProjectTrusted()) ?? undefined;
|
|
435
|
+
if (preToolUseResult) {
|
|
436
|
+
if (preToolUseResult.decision === "deny") {
|
|
437
|
+
return { block: true, reason: preToolUseResult.reason };
|
|
438
|
+
}
|
|
439
|
+
if (preToolUseResult.decision === "allow") {
|
|
440
|
+
// Allow bypasses Guardian/confirm, but the exec policy's forbidden
|
|
441
|
+
// band still runs as a hard safety floor (deliberate deviation
|
|
442
|
+
// from Claude Code: we don't let a hook auto-allow a forbidden cmd).
|
|
443
|
+
if (event.toolName === "bash") {
|
|
444
|
+
const cmdRaw = input.command;
|
|
445
|
+
const command = typeof cmdRaw === "string" ? cmdRaw.trim() : "";
|
|
446
|
+
if (command) {
|
|
447
|
+
const execPolicy = effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY;
|
|
448
|
+
const classification = classifyCommand(command, execPolicy);
|
|
449
|
+
if (classification.decision === "forbidden") {
|
|
450
|
+
return {
|
|
451
|
+
block: true,
|
|
452
|
+
reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return {};
|
|
458
|
+
}
|
|
459
|
+
// "ask" → force confirm by overriding the gate decision
|
|
460
|
+
// Falls through to decision.confirm logic below
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// Fail-soft: a hook error never blocks or allows; fall through to gate
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
let decision = decideGate(event.toolName, input, modeAtEntry, effectivePolicy);
|
|
426
468
|
if (decision.block)
|
|
427
469
|
return { block: true, reason: decision.reason };
|
|
428
470
|
// Prompt band (YAG-510 order): grants → exact-command cache → cap/
|
|
@@ -587,6 +629,41 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
587
629
|
reason: `Guardian needs user approval: ${verdict.rationale} No UI available — the command was held. Find a safer alternative or leave this step for the user.`,
|
|
588
630
|
};
|
|
589
631
|
}
|
|
632
|
+
// YAG-506: PermissionRequest hooks fire before the confirm dialog.
|
|
633
|
+
// Only when a UI is present (headless path already failed closed above).
|
|
634
|
+
if (hookRunner && ctx?.hasUI) {
|
|
635
|
+
try {
|
|
636
|
+
const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
|
|
637
|
+
if (hookResult) {
|
|
638
|
+
if (hookResult.decision === "allow") {
|
|
639
|
+
rememberApproved(cwd, command);
|
|
640
|
+
emitGateEvent({
|
|
641
|
+
...eventBase,
|
|
642
|
+
outcome: "ask_approved",
|
|
643
|
+
riskLevel: verdict.riskLevel,
|
|
644
|
+
rationale: verdict.rationale,
|
|
645
|
+
durationMs,
|
|
646
|
+
consulted: true,
|
|
647
|
+
});
|
|
648
|
+
return {};
|
|
649
|
+
}
|
|
650
|
+
if (hookResult.decision === "deny") {
|
|
651
|
+
emitGateEvent({
|
|
652
|
+
...eventBase,
|
|
653
|
+
outcome: "ask_denied",
|
|
654
|
+
riskLevel: verdict.riskLevel,
|
|
655
|
+
rationale: verdict.rationale,
|
|
656
|
+
durationMs,
|
|
657
|
+
consulted: true,
|
|
658
|
+
});
|
|
659
|
+
return { block: true, reason: hookResult.reason };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// Fail-soft: hook error → dialog proceeds normally
|
|
665
|
+
}
|
|
666
|
+
}
|
|
590
667
|
// Offer "don't ask again" only when the grant would actually
|
|
591
668
|
// cover this command (grant-time validation).
|
|
592
669
|
const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
|
|
@@ -725,7 +802,29 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
725
802
|
}
|
|
726
803
|
// review mode with Guardian disabled/capped: fall through to confirm.
|
|
727
804
|
}
|
|
805
|
+
// YAG-506: PreToolUse "ask" forces confirmation even in auto mode.
|
|
806
|
+
// Uses the cached result from the top of the handler — no re-invocation.
|
|
807
|
+
if (preToolUseResult?.decision === "ask") {
|
|
808
|
+
decision = { block: false, confirm: true };
|
|
809
|
+
}
|
|
728
810
|
if (decision.confirm) {
|
|
811
|
+
// YAG-506: PermissionRequest hooks run before the confirm dialog.
|
|
812
|
+
if (hookRunner) {
|
|
813
|
+
try {
|
|
814
|
+
const cwd = ctx?.cwd ?? ".";
|
|
815
|
+
const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
|
|
816
|
+
if (hookResult) {
|
|
817
|
+
if (hookResult.decision === "allow")
|
|
818
|
+
return {};
|
|
819
|
+
if (hookResult.decision === "deny") {
|
|
820
|
+
return { block: true, reason: hookResult.reason };
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
// Fail-soft: hook error → dialog proceeds normally
|
|
826
|
+
}
|
|
827
|
+
}
|
|
729
828
|
// Review mode needs a confirmation. With no dialog-capable UI (headless),
|
|
730
829
|
// fail CLOSED: the user explicitly chose a stricter mode, so a write we
|
|
731
830
|
// cannot get consent for is held rather than silently auto-applied (this
|
|
@@ -832,4 +931,4 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
832
931
|
},
|
|
833
932
|
});
|
|
834
933
|
}
|
|
835
|
-
//# sourceMappingURL=
|
|
934
|
+
//# sourceMappingURL=gate.js.map
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
* exoneration — so an ask-preferring model cannot disarm the breaker by
|
|
28
28
|
* alternating deny/ask.
|
|
29
29
|
*/
|
|
30
|
-
import { runStage as defaultRunStage } from "
|
|
31
|
-
import type { PipelineStage } from "
|
|
30
|
+
import { runStage as defaultRunStage } from "../pipeline/runner.js";
|
|
31
|
+
import type { PipelineStage } from "../pipeline/types.js";
|
|
32
32
|
export type GuardianOutcome = "allow" | "ask" | "deny";
|
|
33
33
|
export type GuardianRiskLevel = "low" | "medium" | "high" | "critical";
|
|
34
34
|
export interface GuardianVerdict {
|
|
@@ -45,13 +45,32 @@ export interface GuardianLimits {
|
|
|
45
45
|
timeoutMs: number;
|
|
46
46
|
}
|
|
47
47
|
export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
|
|
48
|
+
/**
|
|
49
|
+
* Client-side safety margin on top of the backend's advertised wall ceiling
|
|
50
|
+
* (YAG-562). The backend now owns the real timeout (first-output + wall) and
|
|
51
|
+
* retries once, so the client must NOT pre-empt it; it derives its own
|
|
52
|
+
* deadline from the catalog's `guardianTimeoutMs * attempts` plus this margin
|
|
53
|
+
* for child boot + read-tool round-trips + teardown, and treats that bound as
|
|
54
|
+
* a last-resort floor, not the normal path.
|
|
55
|
+
*/
|
|
56
|
+
export declare const GUARDIAN_CLIENT_OVERHEAD_MS = 10000;
|
|
48
57
|
/**
|
|
49
58
|
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
50
59
|
* overrides the sliding-window review cap; anything non-numeric or < 1 falls
|
|
51
60
|
* back to the default (a bad value must never zero out the cap and lock the
|
|
52
|
-
* session).
|
|
61
|
+
* session). `YAGNI_GUARDIAN_TIMEOUT_MS` overrides the consult deadline for a
|
|
62
|
+
* single developer (previously documented but unwired).
|
|
53
63
|
*/
|
|
54
64
|
export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
|
|
65
|
+
/**
|
|
66
|
+
* Derive the client's whole-consult deadline from the backend's advertised
|
|
67
|
+
* wall ceiling and attempt count (YAG-562). Falls back to the resolved limit
|
|
68
|
+
* when the catalog omitted the fields (older backend).
|
|
69
|
+
*/
|
|
70
|
+
export declare function deriveGuardianTimeoutMs(base: GuardianLimits, advertised: {
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
maxAttempts?: number;
|
|
73
|
+
}): number;
|
|
55
74
|
/** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
|
|
56
75
|
export declare const GUARDIAN_MODEL_TIER = "efficient";
|
|
57
76
|
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
@@ -27,23 +27,47 @@
|
|
|
27
27
|
* exoneration — so an ask-preferring model cannot disarm the breaker by
|
|
28
28
|
* alternating deny/ask.
|
|
29
29
|
*/
|
|
30
|
-
import { runStage as defaultRunStage } from "
|
|
30
|
+
import { runStage as defaultRunStage } from "../pipeline/runner.js";
|
|
31
31
|
export const DEFAULT_GUARDIAN_LIMITS = {
|
|
32
32
|
maxReviews: 120,
|
|
33
33
|
maxConsecutiveDenials: 3,
|
|
34
34
|
timeoutMs: 15_000,
|
|
35
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* Client-side safety margin on top of the backend's advertised wall ceiling
|
|
38
|
+
* (YAG-562). The backend now owns the real timeout (first-output + wall) and
|
|
39
|
+
* retries once, so the client must NOT pre-empt it; it derives its own
|
|
40
|
+
* deadline from the catalog's `guardianTimeoutMs * attempts` plus this margin
|
|
41
|
+
* for child boot + read-tool round-trips + teardown, and treats that bound as
|
|
42
|
+
* a last-resort floor, not the normal path.
|
|
43
|
+
*/
|
|
44
|
+
export const GUARDIAN_CLIENT_OVERHEAD_MS = 10_000;
|
|
36
45
|
/**
|
|
37
46
|
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
38
47
|
* overrides the sliding-window review cap; anything non-numeric or < 1 falls
|
|
39
48
|
* back to the default (a bad value must never zero out the cap and lock the
|
|
40
|
-
* session).
|
|
49
|
+
* session). `YAGNI_GUARDIAN_TIMEOUT_MS` overrides the consult deadline for a
|
|
50
|
+
* single developer (previously documented but unwired).
|
|
41
51
|
*/
|
|
42
52
|
export function resolveGuardianLimits(env = process.env) {
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const maxReviews = Number.isFinite(
|
|
46
|
-
|
|
53
|
+
const rawReviews = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
|
|
54
|
+
const parsedReviews = rawReviews ? Number.parseInt(rawReviews, 10) : NaN;
|
|
55
|
+
const maxReviews = Number.isFinite(parsedReviews) && parsedReviews >= 1 ? parsedReviews : DEFAULT_GUARDIAN_LIMITS.maxReviews;
|
|
56
|
+
const rawTimeout = env.YAGNI_GUARDIAN_TIMEOUT_MS?.trim();
|
|
57
|
+
const parsedTimeout = rawTimeout ? Number.parseInt(rawTimeout, 10) : NaN;
|
|
58
|
+
const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 1 ? parsedTimeout : DEFAULT_GUARDIAN_LIMITS.timeoutMs;
|
|
59
|
+
return { ...DEFAULT_GUARDIAN_LIMITS, maxReviews, timeoutMs };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Derive the client's whole-consult deadline from the backend's advertised
|
|
63
|
+
* wall ceiling and attempt count (YAG-562). Falls back to the resolved limit
|
|
64
|
+
* when the catalog omitted the fields (older backend).
|
|
65
|
+
*/
|
|
66
|
+
export function deriveGuardianTimeoutMs(base, advertised) {
|
|
67
|
+
if (advertised.timeoutMs !== undefined && advertised.maxAttempts !== undefined) {
|
|
68
|
+
return advertised.timeoutMs * advertised.maxAttempts + GUARDIAN_CLIENT_OVERHEAD_MS;
|
|
69
|
+
}
|
|
70
|
+
return base.timeoutMs;
|
|
47
71
|
}
|
|
48
72
|
/** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
|
|
49
73
|
export const GUARDIAN_MODEL_TIER = "efficient";
|
|
@@ -190,7 +214,19 @@ export async function reviewCommand(command, deps) {
|
|
|
190
214
|
if (deps.signal?.aborted) {
|
|
191
215
|
return { verdict: null, error: "aborted", cost };
|
|
192
216
|
}
|
|
217
|
+
// The backend now owns the timeout (YAG-562) and streams a terminal error
|
|
218
|
+
// frame when the consult fails; a result with a `stopReason: "error"` or an
|
|
219
|
+
// `errorMessage` is a real backend failure, NOT the silent-empty shape. Map
|
|
220
|
+
// it to a concrete error so the gate shows "review timed out" / "service
|
|
221
|
+
// unavailable" instead of the misleading "no response". A timeout-shaped
|
|
222
|
+
// message (stream_idle_timeout / "timed out") is distinguished so the gate
|
|
223
|
+
// can add its timeout-specific note.
|
|
193
224
|
if (!output) {
|
|
225
|
+
if (result.stopReason === "error" || result.errorMessage) {
|
|
226
|
+
const msg = (result.errorMessage ?? "").toLowerCase();
|
|
227
|
+
const isTimeout = /stream_idle_timeout|timed out|idle|wall|timeout/i.test(msg);
|
|
228
|
+
return { verdict: null, error: isTimeout ? "timeout" : "network", cost };
|
|
229
|
+
}
|
|
194
230
|
return { verdict: null, error: "empty", cost };
|
|
195
231
|
}
|
|
196
232
|
const verdict = parseVerdict(output);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission domain barrel.
|
|
3
|
+
*
|
|
4
|
+
* This file is the *public* re-export surface ONLY. Internal modules import
|
|
5
|
+
* each other directly (gate.ts → ./execPolicy.js), never through this barrel,
|
|
6
|
+
* to keep the dependency graph grep-able and avoid circular imports.
|
|
7
|
+
*
|
|
8
|
+
* See AGENTS.md alongside this directory for the seam map and how to extend.
|
|
9
|
+
*/
|
|
10
|
+
export * from "./execPolicy.js";
|
|
11
|
+
export * from "./guardian.js";
|
|
12
|
+
export * from "./approvedPrefixes.js";
|
|
13
|
+
export * from "./gate.js";
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission domain barrel.
|
|
3
|
+
*
|
|
4
|
+
* This file is the *public* re-export surface ONLY. Internal modules import
|
|
5
|
+
* each other directly (gate.ts → ./execPolicy.js), never through this barrel,
|
|
6
|
+
* to keep the dependency graph grep-able and avoid circular imports.
|
|
7
|
+
*
|
|
8
|
+
* See AGENTS.md alongside this directory for the seam map and how to extend.
|
|
9
|
+
*/
|
|
10
|
+
export * from "./execPolicy.js";
|
|
11
|
+
export * from "./guardian.js";
|
|
12
|
+
export * from "./approvedPrefixes.js";
|
|
13
|
+
export * from "./gate.js";
|
|
14
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-manager and dev-loop auto-allow policy (YAG-561).
|
|
3
|
+
*
|
|
4
|
+
* Companion to execPolicy.ts: this file owns the *data* and *parsing* for the
|
|
5
|
+
* package-manager / dev-loop band, while execPolicy.ts owns the core classifier
|
|
6
|
+
* (rule matching, construct floor, danger scan) and wires these helpers in.
|
|
7
|
+
*
|
|
8
|
+
* Two independent mechanisms live here:
|
|
9
|
+
*
|
|
10
|
+
* 1. Workspace-selector normalization — `pnpm --filter <pkg> test:file` should
|
|
11
|
+
* match the same rules as `pnpm test:file`. We strip ONLY the value-bearing
|
|
12
|
+
* "which workspace" options (never arbitrary flags), because an option we
|
|
13
|
+
* do NOT recognize must degrade to `prompt`, never to `allow`.
|
|
14
|
+
*
|
|
15
|
+
* 2. Exec/dlx forwarders — `npx`, `pnpm exec`, `npm exec`, `yarn exec`,
|
|
16
|
+
* `pnpm dlx`, `yarn dlx`, and `xargs` all forward to their argv tail. The
|
|
17
|
+
* tail is classified as its own segment so `pnpm exec rm -rf` inherits rm's
|
|
18
|
+
* forbidden floor and `npx tsc --noEmit` inherits tsc's allow.
|
|
19
|
+
*
|
|
20
|
+
* The allow rules themselves (`PACKAGE_MANAGER_ALLOW_RULES`) cover:
|
|
21
|
+
* - dev-loop binaries reachable through a forwarder (and directly):
|
|
22
|
+
* tsc (requires --noEmit), tsx (requires --test), vitest, jest;
|
|
23
|
+
* - script namespaces `test:*` / `build:*` / `lint:*` across pnpm/npm/yarn.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately NOT auto-allowed here: arbitrary `pnpm exec <binary>`, any
|
|
26
|
+
* `run <script>` outside the three namespaces (`pnpm run deploy` is prompt),
|
|
27
|
+
* and `pnpm install/add/remove` (they mutate node_modules). Those stay in the
|
|
28
|
+
* prompt band and go to the Guardian.
|
|
29
|
+
*
|
|
30
|
+
* See AGENTS.md alongside this directory for the "add a new spelling" recipe.
|
|
31
|
+
*/
|
|
32
|
+
import type { PrefixRule } from "./execPolicy.js";
|
|
33
|
+
/**
|
|
34
|
+
* Strip value-bearing "which workspace" options so they don't break prefix
|
|
35
|
+
* matching. Matching-only: the returned tokens are used to CLASSIFY, never to
|
|
36
|
+
* run. Only the handlers below are stripped; anything unrecognized falls
|
|
37
|
+
* through untouched and degrades to `prompt` (fail closed).
|
|
38
|
+
*
|
|
39
|
+
* pnpm: --filter <pkg> / -F <pkg> (+ --filter=<pkg> glue)
|
|
40
|
+
* npm : --workspace <pkg> / -w <pkg> (+ --workspace=<pkg> glue)
|
|
41
|
+
* yarn: (none handled yet — `yarn workspace <name>` is left alone on purpose)
|
|
42
|
+
*/
|
|
43
|
+
export declare function normalizePackageManagerTokens(tokens: string[]): string[];
|
|
44
|
+
/**
|
|
45
|
+
* If `tokens[0]` names a forwarder (xargs / npx / <mgr> exec / <mgr> dlx),
|
|
46
|
+
* return the index at which the forwarded command's argv begins. Return null
|
|
47
|
+
* otherwise. Forwarders exercise `require`d escape hatch: they match only when
|
|
48
|
+
* the manager word is a bare, path-unprefixed, unwrapped token (callers already
|
|
49
|
+
* forced `neverAllow` for wrappers/paths before this runs).
|
|
50
|
+
*/
|
|
51
|
+
export declare function forwarderTailStart(tokens: string[]): number | null;
|
|
52
|
+
/** Human label for a forwarder, used in justification strings. */
|
|
53
|
+
export declare function forwarderLabel(tokens: string[]): string;
|
|
54
|
+
export declare const PACKAGE_MANAGER_ALLOW_RULES: PrefixRule[];
|
|
55
|
+
//# sourceMappingURL=packageManagerPolicy.d.ts.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-manager and dev-loop auto-allow policy (YAG-561).
|
|
3
|
+
*
|
|
4
|
+
* Companion to execPolicy.ts: this file owns the *data* and *parsing* for the
|
|
5
|
+
* package-manager / dev-loop band, while execPolicy.ts owns the core classifier
|
|
6
|
+
* (rule matching, construct floor, danger scan) and wires these helpers in.
|
|
7
|
+
*
|
|
8
|
+
* Two independent mechanisms live here:
|
|
9
|
+
*
|
|
10
|
+
* 1. Workspace-selector normalization — `pnpm --filter <pkg> test:file` should
|
|
11
|
+
* match the same rules as `pnpm test:file`. We strip ONLY the value-bearing
|
|
12
|
+
* "which workspace" options (never arbitrary flags), because an option we
|
|
13
|
+
* do NOT recognize must degrade to `prompt`, never to `allow`.
|
|
14
|
+
*
|
|
15
|
+
* 2. Exec/dlx forwarders — `npx`, `pnpm exec`, `npm exec`, `yarn exec`,
|
|
16
|
+
* `pnpm dlx`, `yarn dlx`, and `xargs` all forward to their argv tail. The
|
|
17
|
+
* tail is classified as its own segment so `pnpm exec rm -rf` inherits rm's
|
|
18
|
+
* forbidden floor and `npx tsc --noEmit` inherits tsc's allow.
|
|
19
|
+
*
|
|
20
|
+
* The allow rules themselves (`PACKAGE_MANAGER_ALLOW_RULES`) cover:
|
|
21
|
+
* - dev-loop binaries reachable through a forwarder (and directly):
|
|
22
|
+
* tsc (requires --noEmit), tsx (requires --test), vitest, jest;
|
|
23
|
+
* - script namespaces `test:*` / `build:*` / `lint:*` across pnpm/npm/yarn.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately NOT auto-allowed here: arbitrary `pnpm exec <binary>`, any
|
|
26
|
+
* `run <script>` outside the three namespaces (`pnpm run deploy` is prompt),
|
|
27
|
+
* and `pnpm install/add/remove` (they mutate node_modules). Those stay in the
|
|
28
|
+
* prompt band and go to the Guardian.
|
|
29
|
+
*
|
|
30
|
+
* See AGENTS.md alongside this directory for the "add a new spelling" recipe.
|
|
31
|
+
*/
|
|
32
|
+
// --- Workspace-selector normalization ---
|
|
33
|
+
/**
|
|
34
|
+
* Strip value-bearing "which workspace" options so they don't break prefix
|
|
35
|
+
* matching. Matching-only: the returned tokens are used to CLASSIFY, never to
|
|
36
|
+
* run. Only the handlers below are stripped; anything unrecognized falls
|
|
37
|
+
* through untouched and degrades to `prompt` (fail closed).
|
|
38
|
+
*
|
|
39
|
+
* pnpm: --filter <pkg> / -F <pkg> (+ --filter=<pkg> glue)
|
|
40
|
+
* npm : --workspace <pkg> / -w <pkg> (+ --workspace=<pkg> glue)
|
|
41
|
+
* yarn: (none handled yet — `yarn workspace <name>` is left alone on purpose)
|
|
42
|
+
*/
|
|
43
|
+
export function normalizePackageManagerTokens(tokens) {
|
|
44
|
+
const cmd = tokens[0];
|
|
45
|
+
if (cmd !== "pnpm" && cmd !== "npm" && cmd !== "yarn")
|
|
46
|
+
return tokens;
|
|
47
|
+
const out = [cmd];
|
|
48
|
+
let i = 1;
|
|
49
|
+
while (i < tokens.length) {
|
|
50
|
+
const t = tokens[i];
|
|
51
|
+
if (cmd === "pnpm") {
|
|
52
|
+
// `--filter pkg` / `-F pkg` → skip option + its value
|
|
53
|
+
if (t === "--filter" || t === "-F") {
|
|
54
|
+
i += 2;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// glued `--filter=pkg` / `-F=pkg` → skip the single token
|
|
58
|
+
if (t.startsWith("--filter=") || t.startsWith("-F=")) {
|
|
59
|
+
i += 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else if (cmd === "npm") {
|
|
64
|
+
// `--workspace pkg` / `-w pkg` → skip option + its value
|
|
65
|
+
if (t === "--workspace" || t === "-w") {
|
|
66
|
+
i += 2;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (t.startsWith("--workspace=") || t.startsWith("-w=")) {
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// First non-selector token (yarn has none we handle) — stop stripping.
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
out.push(...tokens.slice(i));
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
// --- Exec/dlx forwarders ---
|
|
81
|
+
/**
|
|
82
|
+
* If `tokens[0]` names a forwarder (xargs / npx / <mgr> exec / <mgr> dlx),
|
|
83
|
+
* return the index at which the forwarded command's argv begins. Return null
|
|
84
|
+
* otherwise. Forwarders exercise `require`d escape hatch: they match only when
|
|
85
|
+
* the manager word is a bare, path-unprefixed, unwrapped token (callers already
|
|
86
|
+
* forced `neverAllow` for wrappers/paths before this runs).
|
|
87
|
+
*/
|
|
88
|
+
export function forwarderTailStart(tokens) {
|
|
89
|
+
const cmd = tokens[0];
|
|
90
|
+
if (cmd === "xargs" || cmd === "npx")
|
|
91
|
+
return 1;
|
|
92
|
+
if (tokens.length < 2)
|
|
93
|
+
return null;
|
|
94
|
+
const sub = tokens[1];
|
|
95
|
+
if (sub === "exec" && (cmd === "pnpm" || cmd === "npm" || cmd === "yarn"))
|
|
96
|
+
return 2;
|
|
97
|
+
if (sub === "dlx" && (cmd === "pnpm" || cmd === "yarn"))
|
|
98
|
+
return 2;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/** Human label for a forwarder, used in justification strings. */
|
|
102
|
+
export function forwarderLabel(tokens) {
|
|
103
|
+
const cmd = tokens[0];
|
|
104
|
+
if (cmd === "npx" || cmd === "xargs")
|
|
105
|
+
return cmd;
|
|
106
|
+
return tokens.slice(0, 2).join(" ");
|
|
107
|
+
}
|
|
108
|
+
// --- Allow rules ---
|
|
109
|
+
/**
|
|
110
|
+
* The auto-allow rules for the package-manager / dev-loop band. Appended to
|
|
111
|
+
* DEFAULT_EXEC_POLICY.rules by execPolicy.ts (first-match-wins ordering puts
|
|
112
|
+
* these specific rules ahead of the `npx`/`run`/`exec` prompt fallbacks).
|
|
113
|
+
*
|
|
114
|
+
* Safety invariants for every entry:
|
|
115
|
+
* - tsc only allows with --noEmit (bare `tsc` would emit .js/.d.ts).
|
|
116
|
+
* - tsx only allows with --test (bare `tsx file.ts` executes arbitrary TS).
|
|
117
|
+
* - vitest / jest are pure test runners (no mutating flag to fence).
|
|
118
|
+
* - script namespaces are read-only-by-convention test/build/lint and are
|
|
119
|
+
* the ONLY `run` targets allowed; everything else (`run deploy`) is prompt.
|
|
120
|
+
*/
|
|
121
|
+
// The bare script name AND its `name:*` namespace (e.g. both `test` and
|
|
122
|
+
// `test:backend`) — declared once so each manager rule reuses the same set.
|
|
123
|
+
const SCRIPT_NAMESPACES = ["test", "test:*", "build", "build:*", "lint", "lint:*"];
|
|
124
|
+
export const PACKAGE_MANAGER_ALLOW_RULES = [
|
|
125
|
+
// dev-loop binaries (reachable directly or through a forwarder)
|
|
126
|
+
{
|
|
127
|
+
pattern: ["tsc"],
|
|
128
|
+
flagsAnywhere: ["--noEmit"],
|
|
129
|
+
decision: "allow",
|
|
130
|
+
justification: "typecheck only (--noEmit writes no files)",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
pattern: ["tsx"],
|
|
134
|
+
flagsAnywhere: ["--test"],
|
|
135
|
+
decision: "allow",
|
|
136
|
+
justification: "run tests via tsx (--test only, no script execution)",
|
|
137
|
+
},
|
|
138
|
+
{ pattern: ["vitest"], decision: "allow", justification: "run vitest tests (routine dev-loop operation)" },
|
|
139
|
+
{ pattern: ["vitest", "run"], decision: "allow", justification: "run vitest tests (routine dev-loop operation)" },
|
|
140
|
+
{ pattern: ["jest"], decision: "allow", justification: "run jest tests (routine dev-loop operation)" },
|
|
141
|
+
// script namespaces — direct form (pnpm/yarn support `mgr <script>`) and
|
|
142
|
+
// universal `run <script>` form. npm direct form is omitted: npm requires
|
|
143
|
+
// `run` for colon-namespaced scripts.
|
|
144
|
+
{
|
|
145
|
+
pattern: ["pnpm", SCRIPT_NAMESPACES],
|
|
146
|
+
decision: "allow",
|
|
147
|
+
justification: "run a test/build/lint script (routine dev-loop operation)",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
pattern: ["pnpm", "run", SCRIPT_NAMESPACES],
|
|
151
|
+
decision: "allow",
|
|
152
|
+
justification: "run a test/build/lint script (routine dev-loop operation)",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
pattern: ["yarn", SCRIPT_NAMESPACES],
|
|
156
|
+
decision: "allow",
|
|
157
|
+
justification: "run a test/build/lint script (routine dev-loop operation)",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
pattern: ["yarn", "run", SCRIPT_NAMESPACES],
|
|
161
|
+
decision: "allow",
|
|
162
|
+
justification: "run a test/build/lint script (routine dev-loop operation)",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
pattern: ["npm", "run", SCRIPT_NAMESPACES],
|
|
166
|
+
decision: "allow",
|
|
167
|
+
justification: "run a test/build/lint script (routine dev-loop operation)",
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
//# sourceMappingURL=packageManagerPolicy.js.map
|