@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.
- package/dist/cli.js +13 -0
- package/dist/crashReport.d.ts +12 -0
- package/dist/crashReport.js +28 -1
- package/dist/extension/crashReport.d.ts +18 -0
- package/dist/extension/crashReport.js +35 -2
- 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} +99 -8
- package/dist/extension/{permission.d.ts → permission/gate.d.ts} +10 -3
- package/dist/extension/{permission.js → permission/gate.js} +156 -9
- 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 +31 -3
- package/dist/extension/pipeline/invocation.js +37 -3
- 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 +118 -7
- 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/subagents.js +13 -0
- 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/dist/promptEnrichment.d.ts +1 -1
- package/dist/promptEnrichment.js +1 -1
- 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
|
}
|
|
@@ -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
|
-
//
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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=
|
|
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;
|
|
@@ -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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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=
|
|
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 {
|
|
@@ -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 "
|
|
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
|