@yagni-app/code 0.3.1 → 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.
Files changed (65) hide show
  1. package/dist/cli.js +13 -0
  2. package/dist/crashReport.d.ts +12 -0
  3. package/dist/crashReport.js +28 -1
  4. package/dist/extension/crashReport.d.ts +18 -0
  5. package/dist/extension/crashReport.js +35 -2
  6. package/dist/extension/footer.d.ts +1 -1
  7. package/dist/extension/hooks.d.ts +111 -0
  8. package/dist/extension/hooks.js +666 -0
  9. package/dist/extension/index.d.ts +13 -6
  10. package/dist/extension/index.js +57 -7
  11. package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
  12. package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
  13. package/dist/extension/permission/dbReadPolicy.js +227 -0
  14. package/dist/extension/{execPolicy.js → permission/execPolicy.js} +99 -8
  15. package/dist/extension/{permission.d.ts → permission/gate.d.ts} +10 -3
  16. package/dist/extension/{permission.js → permission/gate.js} +156 -9
  17. package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +2 -2
  18. package/dist/extension/{guardian.js → permission/guardian.js} +1 -1
  19. package/dist/extension/permission/index.d.ts +14 -0
  20. package/dist/extension/permission/index.js +14 -0
  21. package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
  22. package/dist/extension/permission/packageManagerPolicy.js +170 -0
  23. package/dist/extension/pipeline/activityFeed.js +19 -5
  24. package/dist/extension/pipeline/checker.d.ts +99 -0
  25. package/dist/extension/pipeline/checker.js +238 -0
  26. package/dist/extension/pipeline/fanout.d.ts +116 -0
  27. package/dist/extension/pipeline/fanout.js +248 -0
  28. package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
  29. package/dist/extension/pipeline/fanoutBeats.js +86 -0
  30. package/dist/extension/pipeline/goCommand.d.ts +14 -0
  31. package/dist/extension/pipeline/goCommand.js +38 -1
  32. package/dist/extension/pipeline/headlessGo.d.ts +163 -0
  33. package/dist/extension/pipeline/headlessGo.js +333 -0
  34. package/dist/extension/pipeline/invocation.d.ts +31 -3
  35. package/dist/extension/pipeline/invocation.js +37 -3
  36. package/dist/extension/pipeline/mission.d.ts +55 -0
  37. package/dist/extension/pipeline/mission.js +70 -0
  38. package/dist/extension/pipeline/orchestrator.d.ts +48 -3
  39. package/dist/extension/pipeline/orchestrator.js +450 -9
  40. package/dist/extension/pipeline/personas.d.ts +16 -1
  41. package/dist/extension/pipeline/personas.js +118 -7
  42. package/dist/extension/pipeline/runSession.d.ts +45 -1
  43. package/dist/extension/pipeline/runState.d.ts +57 -12
  44. package/dist/extension/pipeline/runState.js +60 -18
  45. package/dist/extension/pipeline/runner.js +10 -1
  46. package/dist/extension/pipeline/stages.d.ts +84 -7
  47. package/dist/extension/pipeline/stages.js +166 -0
  48. package/dist/extension/pipeline/tierCap.d.ts +32 -0
  49. package/dist/extension/pipeline/tierCap.js +57 -0
  50. package/dist/extension/pipeline/types.d.ts +130 -1
  51. package/dist/extension/pipeline/types.js +17 -0
  52. package/dist/extension/pipeline/verify.d.ts +86 -3
  53. package/dist/extension/pipeline/verify.js +175 -6
  54. package/dist/extension/subagents.js +13 -0
  55. package/dist/extension/turnLog.d.ts +38 -0
  56. package/dist/extension/turnLog.js +93 -0
  57. package/dist/goHeadless.d.ts +75 -0
  58. package/dist/goHeadless.js +132 -0
  59. package/dist/paths.d.ts +9 -0
  60. package/dist/paths.js +12 -0
  61. package/dist/promptEnrichment.d.ts +1 -1
  62. package/dist/promptEnrichment.js +1 -1
  63. package/package.json +2 -2
  64. /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
  65. /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
- if (pat !== tok)
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.includes(tok))
585
+ if (!pat.some((entry) => tokenMatchesEntry(tok, entry)))
582
586
  return false;
583
587
  }
584
588
  }
@@ -612,6 +616,16 @@ function classifySegmentTokens(rawTokens, policy, opts) {
612
616
  }
613
617
  const { tokens: strippedTokens, stripped } = stripLeadingTokens(rawTokens);
614
618
  if (strippedTokens.length === 0) {
619
+ // env standalone: `env` or `env VAR=val` with no following command prints
620
+ // environment variables — a read-only operation. stripLeadingTokens removes
621
+ // `env` as a wrapper word, leaving empty tokens. Recognize this case instead
622
+ // of returning "empty command segment" (YAG-549).
623
+ if (!opts.forbiddenOnly && rawTokens.length > 0 && basenameToken(rawTokens[0]) === "env") {
624
+ const onlyEnvAndAssignments = rawTokens.every((t) => basenameToken(t) === "env" || ENV_ASSIGNMENT_RE.test(t));
625
+ if (onlyEnvAndAssignments) {
626
+ return { decision: "allow", justification: "print environment variables (read-only)" };
627
+ }
628
+ }
615
629
  return opts.forbiddenOnly
616
630
  ? { decision: "allow", justification: "no forbidden match" }
617
631
  : { decision: "prompt", justification: "empty command segment" };
@@ -623,11 +637,28 @@ function classifySegmentTokens(rawTokens, policy, opts) {
623
637
  const pathPrefixed = normalizedWord !== cmdWord;
624
638
  let tokens = pathPrefixed ? [normalizedWord, ...strippedTokens.slice(1)] : strippedTokens;
625
639
  tokens = normalizeGitTokens(tokens);
640
+ tokens = normalizePackageManagerTokens(tokens);
626
641
  const neverAllow = stripped || pathPrefixed;
627
- // xargs forwards to its argv tail: classify the tail as its own segment so
628
- // `xargs rm -rf` inherits rm's forbidden. xargs itself is never allow.
629
- if (tokens[0] === "xargs" && opts.depth < MAX_SCAN_DEPTH) {
630
- let j = 1;
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;
631
662
  while (j < tokens.length && tokens[j].startsWith("-"))
632
663
  j++;
633
664
  const tail = tokens.slice(j);
@@ -636,12 +667,18 @@ function classifySegmentTokens(rawTokens, policy, opts) {
636
667
  if (tailResult.decision === "forbidden")
637
668
  return tailResult;
638
669
  if (tailResult.decision === "allow" && !neverAllow) {
639
- return { decision: "allow", justification: "xargs forwards to a read-only command" };
670
+ return {
671
+ decision: "allow",
672
+ justification: `${forwarderLabel(tokens)} forwards to a read-only command`,
673
+ };
640
674
  }
641
675
  }
642
676
  if (opts.forbiddenOnly)
643
677
  return { decision: "allow", justification: "no forbidden match" };
644
- return { decision: "prompt", justification: "xargs executes its argument command — review the target" };
678
+ return {
679
+ decision: "prompt",
680
+ justification: `${forwarderLabel(tokens)} executes its argument command — review the target`,
681
+ };
645
682
  }
646
683
  // First match wins (rules are ordered; more specific rules come first).
647
684
  for (const rule of policy.rules) {
@@ -894,6 +931,60 @@ export const DEFAULT_EXEC_POLICY = {
894
931
  { pattern: ["pnpm", "list"], decision: "allow", justification: "list installed packages" },
895
932
  { pattern: ["pnpm", "--version"], decision: "allow", justification: "check pnpm version" },
896
933
  { pattern: ["tsc", "--version"], decision: "allow", justification: "check typescript version" },
934
+ // --- allow: CLI tool reads (YAG-549, based on prod Guardian data) ---
935
+ // linear CLI — read subcommands; write subcommands (create/update/delete/start/pr/attach/comment) stay prompt
936
+ { pattern: ["linear", "issue", "comment", "list"], decision: "allow", justification: "list Linear issue comments (read-only)" },
937
+ {
938
+ pattern: ["linear", "issue"],
939
+ decision: "allow",
940
+ justification: "read Linear issue data",
941
+ unlessTokens: ["start", "create", "update", "delete", "pull-request", "pr", "attach", "comment"],
942
+ },
943
+ { pattern: ["linear", ["--help", "-h"]], decision: "allow", justification: "show Linear CLI help" },
944
+ { pattern: ["linear", ["--version", "-V"]], decision: "allow", justification: "show Linear CLI version" },
945
+ // gh api — REST GET (no params, no method override) and GraphQL queries (no mutation)
946
+ // gh api defaults to POST when -f/-F params are present, so params are disqualifying.
947
+ // GraphQL is always POST, but queries are reads; mutations/subscriptions are disqualifying.
948
+ {
949
+ pattern: ["gh", "api", "graphql"],
950
+ decision: "allow",
951
+ justification: "GraphQL query (read-only)",
952
+ unlessTokens: ["query=mutation*", "query=subscription*"],
953
+ },
954
+ {
955
+ pattern: ["gh", "api"],
956
+ decision: "allow",
957
+ justification: "GitHub API GET request (read-only)",
958
+ unlessTokens: ["--method", "-X", "-X*", "graphql", "POST", "PATCH", "DELETE", "PUT", "-f", "-F", "--raw-field", "--field"],
959
+ },
960
+ { pattern: ["gh", "release", ["list", "view"]], decision: "allow", justification: "read GitHub release data" },
961
+ { pattern: ["gh", "label", "list"], decision: "allow", justification: "list GitHub labels" },
962
+ { pattern: ["gh", "milestone", "list"], decision: "allow", justification: "list GitHub milestones" },
963
+ { pattern: ["gh", "auth", "status"], decision: "allow", justification: "show GitHub auth status" },
964
+ // git read-only subcommands
965
+ { pattern: ["git", "tag"], decision: "allow", justification: "list tags (read-only)" },
966
+ { pattern: ["git", "reflog"], decision: "allow", justification: "show reference log" },
967
+ { pattern: ["git", "ls-remote"], decision: "allow", justification: "list remote refs" },
968
+ { pattern: ["git", "cat-file"], decision: "allow", justification: "inspect git objects (read-only)" },
969
+ { pattern: ["git", "for-each-ref"], decision: "allow", justification: "enumerate refs (read-only)" },
970
+ { pattern: ["git", "rev-list"], decision: "allow", justification: "walk commit history (read-only)" },
971
+ { pattern: ["git", "show-ref"], decision: "allow", justification: "list all refs" },
972
+ { pattern: ["git", "name-rev"], decision: "allow", justification: "map commit to name (read-only)" },
973
+ { pattern: ["git", "merge-base"], decision: "allow", justification: "find common ancestor (read-only)" },
974
+ // package manager test/lint — routine dev-loop operations
975
+ { pattern: ["pnpm", "test"], decision: "allow", justification: "run tests (routine dev-loop operation)" },
976
+ { pattern: ["pnpm", "lint"], decision: "allow", justification: "run linter (routine dev-loop operation)" },
977
+ { pattern: ["npm", "test"], decision: "allow", justification: "run tests (routine dev-loop operation)" },
978
+ { pattern: ["npm", "run", "lint"], decision: "allow", justification: "run linter (routine dev-loop operation)" },
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,
985
+ // misc read-only commands
986
+ { pattern: ["printenv"], decision: "allow", justification: "print environment variables (read-only)" },
987
+ { pattern: ["npm", ["view", "info"]], decision: "allow", justification: "read package metadata from registry" },
897
988
  // --- prompt: potentially destructive but context-dependent ---
898
989
  { pattern: ["rm"], decision: "prompt", justification: "file deletion — review the target" },
899
990
  { pattern: ["git", "commit"], decision: "prompt", justification: "creates a commit — confirm intent" },
@@ -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 { type BlessStore } from "./bless.js";
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,12 +180,18 @@ 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";
185
192
  /** Legacy alias — the original plan-mode tag, kept for backward compat. */
186
193
  export declare const PLAN_CONTEXT_TYPE = "yagni-mode-context";
187
- export declare const PLAN_CONTEXT_MESSAGE = "[PLAN MODE ACTIVE]\nYou are in plan mode: explore and design, change nothing.\n- write, edit, and bash are held by the permission gate; do not attempt them.\n- Read, search, and ask_yagni freely to ground the plan in how this company works.\n- Produce a concrete numbered plan of the steps you would take, with the files involved.\n- End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.\n- Once executing, track the plan's steps with todo_write.";
194
+ export declare const PLAN_CONTEXT_MESSAGE = "[PLAN MODE ACTIVE]\nYou are in plan mode: explore and design, change nothing.\n- Read-only bash commands (ls, grep, git status, gh pr view, etc.) run freely to help you explore.\n- Ambiguous bash commands are reviewed by the Guardian; if non-mutating they run, if potentially mutating you will be asked.\n- write, edit, file_ticket, and update_ticket_status are held by the permission gate; do not attempt them.\n- Read, search, and ask_yagni freely to ground the plan in how this company works.\n- Produce a concrete numbered plan of the steps you would take, with the files involved.\n- End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.\n- Once executing, track the plan's steps with todo_write.";
188
195
  /** Build the mode-awareness context message for the current permission mode. */
189
196
  export declare function buildModeContextMessage(mode: PermissionMode): string;
190
197
  /**
@@ -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=permission.d.ts.map
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 "./bless.js";
30
+ import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
31
31
  import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
32
- import { isDebug } from "./diagnostics.js";
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;
@@ -56,6 +56,42 @@ export const DEFAULT_PERMISSION_POLICY = {
56
56
  */
57
57
  export function decideGate(toolName, params, mode, policy) {
58
58
  if (mode === "plan") {
59
+ // Non-bash tools in planBlockTools are held outright — they are
60
+ // inherently mutating (write, edit, file_ticket, update_ticket_status).
61
+ // Bash is the exploration tool: run it through the exec policy so
62
+ // read-only commands (git status, ls, grep, gh pr view) work, and
63
+ // prompt-band commands are routed to the Guardian. The gate handler
64
+ // ensures Guardian-unavailable/capped/disabled states fail closed.
65
+ if (toolName === "bash") {
66
+ const command = typeof params.command === "string" ? params.command.trim() : "";
67
+ if (command) {
68
+ try {
69
+ const execPolicy = policy.execPolicy ?? DEFAULT_EXEC_POLICY;
70
+ const classification = classifyCommand(command, execPolicy);
71
+ if (classification.decision === "allow")
72
+ return { block: false };
73
+ if (classification.decision === "forbidden") {
74
+ return {
75
+ block: true,
76
+ reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
77
+ };
78
+ }
79
+ // prompt — Guardian reviews. The gate handler runs the Guardian
80
+ // and handles allow/ask/deny. Grants and cache are skipped in
81
+ // plan mode (they can cover writes). Guardian unavailable/capped/
82
+ // disabled → block (fail closed).
83
+ return { block: false, classify: "prompt", classifyJustification: classification.justification };
84
+ }
85
+ catch {
86
+ // classifyCommand threw — fail closed in plan mode.
87
+ return {
88
+ block: true,
89
+ reason: `plan mode: could not classify this bash command and it is held. Switch to /mode auto to apply changes.`,
90
+ };
91
+ }
92
+ }
93
+ return { block: false };
94
+ }
59
95
  if (policy.planBlockTools.includes(toolName)) {
60
96
  return {
61
97
  block: true,
@@ -122,7 +158,9 @@ const AUTO_MARKER = "[AUTO MODE]";
122
158
  const REVIEW_MARKER = "[REVIEW MODE]";
123
159
  export const PLAN_CONTEXT_MESSAGE = `${PLAN_MARKER}
124
160
  You are in plan mode: explore and design, change nothing.
125
- - write, edit, and bash are held by the permission gate; do not attempt them.
161
+ - Read-only bash commands (ls, grep, git status, gh pr view, etc.) run freely to help you explore.
162
+ - Ambiguous bash commands are reviewed by the Guardian; if non-mutating they run, if potentially mutating you will be asked.
163
+ - write, edit, file_ticket, and update_ticket_status are held by the permission gate; do not attempt them.
126
164
  - Read, search, and ask_yagni freely to ground the plan in how this company works.
127
165
  - Produce a concrete numbered plan of the steps you would take, with the files involved.
128
166
  - End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.
@@ -258,6 +296,7 @@ export function registerPermissionGate(pi, deps = {}) {
258
296
  const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
259
297
  let mode = deps.mode ?? "auto";
260
298
  const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
299
+ const hookRunner = deps.hookRunner;
261
300
  deps.modeHolder?.onSet((m) => {
262
301
  if (m !== mode)
263
302
  approvedCommands.clear();
@@ -384,7 +423,48 @@ export function registerPermissionGate(pi, deps = {}) {
384
423
  const modeAtEntry = mode;
385
424
  try {
386
425
  const input = event.input ?? {};
387
- const decision = decideGate(event.toolName, input, modeAtEntry, effectivePolicy);
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);
388
468
  if (decision.block)
389
469
  return { block: true, reason: decision.reason };
390
470
  // Prompt band (YAG-510 order): grants → exact-command cache → cap/
@@ -414,7 +494,9 @@ export function registerPermissionGate(pi, deps = {}) {
414
494
  }
415
495
  }
416
496
  // 2. Session exact-command approval cache (ticket 4.5).
417
- if (command && approvedCommands.has(cacheKey(cwd, command))) {
497
+ // Skipped in plan mode: a cached approval can cover a write command,
498
+ // and plan mode's contract is no mutations without Guardian review.
499
+ if (modeAtEntry !== "plan" && command && approvedCommands.has(cacheKey(cwd, command))) {
418
500
  emitGateEvent({ ...eventBase, outcome: "cached_allow", consulted: false });
419
501
  return {};
420
502
  }
@@ -423,13 +505,13 @@ export function registerPermissionGate(pi, deps = {}) {
423
505
  if (guardianAvailable && guardianState.read().reviews >= limits.maxReviews) {
424
506
  // Sliding-window consult cap (capacity recovers as old reviews age
425
507
  // out — a long-lived session is never bricked). Review mode falls
426
- // through to its ordinary confirm (no LLM cost); auto blocks.
427
- if (modeAtEntry === "auto") {
508
+ // through to its ordinary confirm (no LLM cost); auto and plan block.
509
+ if (modeAtEntry === "auto" || modeAtEntry === "plan") {
428
510
  if (ctx?.hasUI)
429
511
  ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} in the last hour).`, "warning");
430
512
  return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} in the last hour). Capacity recovers as older reviews age out; switch to /mode review to approve manually, or retry this step later.` };
431
513
  }
432
- // fall through to decision.confirm below
514
+ // review mode: fall through to decision.confirm below
433
515
  }
434
516
  else if (guardianAvailable) {
435
517
  // Circuit breaker (pre-consult). With a UI, escalate to ONE ask per
@@ -547,6 +629,41 @@ export function registerPermissionGate(pi, deps = {}) {
547
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.`,
548
630
  };
549
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
+ }
550
667
  // Offer "don't ask again" only when the grant would actually
551
668
  // cover this command (grant-time validation).
552
669
  const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
@@ -675,9 +792,39 @@ export function registerPermissionGate(pi, deps = {}) {
675
792
  // approval cache were already consulted above.
676
793
  return {};
677
794
  }
795
+ else if (modeAtEntry === "plan") {
796
+ // Guardian disabled or not wired in plan mode: fail closed. Without
797
+ // the Guardian to verify the command is non-mutating, the plan-mode
798
+ // contract (no changes) cannot be upheld. The user can switch to
799
+ // /mode auto or /mode review to proceed.
800
+ emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
801
+ return { block: true, reason: "Guardian unavailable in plan mode. Switch to /mode auto to run commands, or /mode review to approve manually." };
802
+ }
678
803
  // review mode with Guardian disabled/capped: fall through to confirm.
679
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
+ }
680
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
+ }
681
828
  // Review mode needs a confirmation. With no dialog-capable UI (headless),
682
829
  // fail CLOSED: the user explicitly chose a stricter mode, so a write we
683
830
  // cannot get consent for is held rather than silently auto-applied (this
@@ -784,4 +931,4 @@ export function registerPermissionGate(pi, deps = {}) {
784
931
  },
785
932
  });
786
933
  }
787
- //# sourceMappingURL=permission.js.map
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 "./pipeline/runner.js";
31
- import type { PipelineStage } from "./pipeline/types.js";
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 {
@@ -27,7 +27,7 @@
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 "./pipeline/runner.js";
30
+ import { runStage as defaultRunStage } from "../pipeline/runner.js";
31
31
  export const DEFAULT_GUARDIAN_LIMITS = {
32
32
  maxReviews: 120,
33
33
  maxConsecutiveDenials: 3,
@@ -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