@nathapp/nax-agent 0.87.0 → 0.88.0-canary.1
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/CHANGELOG.md +14 -0
- package/dist/coding-tools/coding-tool-support.d.ts +15 -1
- package/dist/coding-tools/coding-tool-support.js +19 -8
- package/dist/command-safety/rule-scorer.d.ts +1 -1
- package/dist/command-safety/rule-scorer.js +11 -113
- package/dist/index.d.ts +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/native/errors.js +7 -0
- package/dist/native/session/session.d.ts +3 -0
- package/dist/native/session/session.js +7 -0
- package/dist/native/session/transcript-archive.d.ts +28 -0
- package/dist/native/session/transcript-archive.js +43 -0
- package/dist/session/session-types.d.ts +19 -0
- package/dist/tools/denial-redirect.d.ts +22 -7
- package/dist/tools/denial-redirect.js +29 -13
- package/dist/tools/git-flags/locate.d.ts +47 -0
- package/dist/tools/git-flags/locate.js +361 -0
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/nax-dirs.d.ts +22 -0
- package/dist/tools/nax-dirs.js +89 -0
- package/dist/tools/nax-evasion.d.ts +19 -0
- package/dist/tools/nax-evasion.js +88 -0
- package/dist/tools/nax-revert.d.ts +34 -0
- package/dist/tools/nax-revert.js +233 -0
- package/dist/tools/nax-state-guard.d.ts +18 -0
- package/dist/tools/nax-state-guard.js +496 -0
- package/dist/tools/policy-bash-raw.d.ts +3 -0
- package/dist/tools/policy-bash-raw.js +40 -12
- package/dist/tools/policy-bash.d.ts +3 -0
- package/dist/tools/policy-bash.js +38 -9
- package/dist/tools/policy-command-branch.d.ts +3 -0
- package/dist/tools/policy-command-branch.js +3 -1
- package/dist/tools/policy.js +4 -0
- package/dist/tools/provider-advertise.d.ts +18 -0
- package/dist/tools/provider-advertise.js +16 -5
- package/dist/tools/tool-audit.d.ts +42 -0
- package/dist/tools/tool-audit.js +5 -1
- package/package.json +3 -3
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverts of nax's run state (`.nax/`) through git, for the Bash guard
|
|
3
|
+
* (#2490, #2493).
|
|
4
|
+
*
|
|
5
|
+
* Three ways a revert reaches `.nax/`:
|
|
6
|
+
* 1. an operand that names a `.nax` path (`git checkout -- .nax/x`);
|
|
7
|
+
* 2. a WHOLE-TREE pathspec (`git checkout .`, `git restore --source=HEAD~1 .`,
|
|
8
|
+
* `:/`, `*`, the root directory itself, a pathspec made only of exclusions),
|
|
9
|
+
* unless a full `.nax` exclusion follows `--`;
|
|
10
|
+
* 3. `git reset --hard`, which has no pathspec form at all.
|
|
11
|
+
*
|
|
12
|
+
* For `git checkout` an operand is a PATH only after `--`, when it exists on
|
|
13
|
+
* disk relative to a frame, or when it resolves into `.nax` from the root; so
|
|
14
|
+
* `git checkout feature/.nax` (a branch whose name has a `.nax` segment) is
|
|
15
|
+
* not a revert. `git restore` has no branch operand, so all its operands are paths.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
19
|
+
import { NAX_OWNED_RUN_STATE_EXPLANATION } from "./denial-redirect.js";
|
|
20
|
+
import { findNaxDirs } from "./nax-dirs.js";
|
|
21
|
+
/**
|
|
22
|
+
* The pathspec that covers EVERY `.nax` directory AND the files inside them, named in advice texts. Real git:
|
|
23
|
+
* the glob must end in `/**` (the bare `.nax` glob matches only the directory entry) and be anchored at the
|
|
24
|
+
* top (a relative glob is taken from the working directory).
|
|
25
|
+
*/
|
|
26
|
+
export const COVERING_EXCLUSION = "':(top,exclude,glob)**/.nax/**'";
|
|
27
|
+
/** `:(a,b)body` split on commas; git does not abbreviate long magic names. */
|
|
28
|
+
function parseMagic(spec) {
|
|
29
|
+
const match = /^:\(([^)]*)\)(.*)$/s.exec(spec);
|
|
30
|
+
return match === null ? undefined : { elements: match[1].split(","), body: match[2] };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The path an exclusion pathspec excludes, or undefined when `spec` is not one: `:!X`, `:^X`, or a long form
|
|
34
|
+
* with an element exactly `exclude` and no `attr:` element (an `attr:` spec is an attribute filter whatever
|
|
35
|
+
* else it says).
|
|
36
|
+
*/
|
|
37
|
+
export function exclusionBody(spec) {
|
|
38
|
+
const short = /^:[!^](.*)$/s.exec(spec);
|
|
39
|
+
if (short !== null)
|
|
40
|
+
return short[1];
|
|
41
|
+
const magic = parseMagic(spec);
|
|
42
|
+
if (magic === undefined || !magic.elements.includes("exclude"))
|
|
43
|
+
return undefined;
|
|
44
|
+
return magic.elements.some((e) => e.startsWith("attr")) ? undefined : magic.body;
|
|
45
|
+
}
|
|
46
|
+
export const isExclusionSpec = (spec) => exclusionBody(spec) !== undefined;
|
|
47
|
+
/** Exactly the covering form, magic in any order. */
|
|
48
|
+
export function isCoveringExclusion(spec) {
|
|
49
|
+
const magic = parseMagic(spec);
|
|
50
|
+
if (magic === undefined || magic.body !== "**/.nax/**" || magic.elements.length !== 3)
|
|
51
|
+
return false;
|
|
52
|
+
return ["top", "exclude", "glob"].every((e) => magic.elements.includes(e));
|
|
53
|
+
}
|
|
54
|
+
/** `--force` -> true for `--f`, `--fo`, `--force`: any option abbreviation of `long` (`--x=v` too). */
|
|
55
|
+
export function isLongPrefix(word, long) {
|
|
56
|
+
const name = word.split("=")[0];
|
|
57
|
+
return name.startsWith("--") && name.length >= 3 && long.startsWith(name);
|
|
58
|
+
}
|
|
59
|
+
/** True when `.nax` is a path segment of `path`. */
|
|
60
|
+
export function hasNaxSegment(path) {
|
|
61
|
+
return path.split(/[\\/]/).includes(".nax");
|
|
62
|
+
}
|
|
63
|
+
/** True when `operand`, resolved from any frame, lands on or under a `.nax` directory. */
|
|
64
|
+
export function operandTouchesNax(operand, ctx) {
|
|
65
|
+
if (hasNaxSegment(operand))
|
|
66
|
+
return true;
|
|
67
|
+
return ctx.frames.some((frame) => {
|
|
68
|
+
const rel = relative(ctx.root, resolve(frame, operand));
|
|
69
|
+
return !rel.startsWith("..") && hasNaxSegment(rel);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* An exclusion that covers EVERY `.nax` the guard knows of. The covering glob always does. The literal forms
|
|
74
|
+
* (`:!.nax`, `:^.nax/`, `:(exclude).nax`) name only `<root>/.nax` (resolved from EVERY frame, or from the root
|
|
75
|
+
* under `top`), so they count only while that is the single `.nax` there is.
|
|
76
|
+
*/
|
|
77
|
+
export function isFullNaxExclusion(spec, ctx) {
|
|
78
|
+
if (isCoveringExclusion(spec))
|
|
79
|
+
return true;
|
|
80
|
+
const target = exclusionBody(spec);
|
|
81
|
+
if (target === undefined)
|
|
82
|
+
return false;
|
|
83
|
+
const rootNax = join(ctx.root, ".nax");
|
|
84
|
+
if (findNaxDirs(ctx).some((dir) => dir !== rootNax))
|
|
85
|
+
return false;
|
|
86
|
+
const path = target.replace(/\/(?:\*\*)?$/, "");
|
|
87
|
+
const top = parseMagic(spec)?.elements.includes("top") === true;
|
|
88
|
+
return (top ? [ctx.root] : ctx.frames).every((frame) => resolve(frame, path) === rootNax);
|
|
89
|
+
}
|
|
90
|
+
/** Options whose value occupies the next word, per verb. */
|
|
91
|
+
const VALUE_FLAGS = {
|
|
92
|
+
checkout: new Set(["-b", "-B", "--orphan", "--pathspec-from-file"]),
|
|
93
|
+
restore: new Set(["-s", "--source", "--pathspec-from-file"]),
|
|
94
|
+
clean: new Set(["-e", "--exclude"]),
|
|
95
|
+
};
|
|
96
|
+
function splitOperands(verb, args) {
|
|
97
|
+
const valueFlags = VALUE_FLAGS[verb] ?? new Set();
|
|
98
|
+
const before = [];
|
|
99
|
+
const after = [];
|
|
100
|
+
const flags = [];
|
|
101
|
+
let hasSeparator = false;
|
|
102
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
103
|
+
const word = args[i];
|
|
104
|
+
if (hasSeparator)
|
|
105
|
+
after.push(word);
|
|
106
|
+
else if (word === "--")
|
|
107
|
+
hasSeparator = true;
|
|
108
|
+
else if (word.startsWith("-") && word !== "-") {
|
|
109
|
+
flags.push(word);
|
|
110
|
+
if (valueFlags.has(word))
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
else
|
|
114
|
+
before.push(word);
|
|
115
|
+
}
|
|
116
|
+
return { before, after, hasSeparator, flags };
|
|
117
|
+
}
|
|
118
|
+
/** A checkout operand that is a path although no `--` marks it: it exists on disk, or lands in `.nax` from the root. */
|
|
119
|
+
function isUnmarkedCheckoutPath(word, ctx) {
|
|
120
|
+
return ctx.frames.some((frame) => {
|
|
121
|
+
const target = resolve(frame, word);
|
|
122
|
+
if (existsSync(target))
|
|
123
|
+
return true;
|
|
124
|
+
return relative(ctx.root, target).split(/[\\/]/)[0] === ".nax";
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function pathOperands(verb, ops, ctx) {
|
|
128
|
+
if (verb === "restore")
|
|
129
|
+
return [...ops.before, ...ops.after];
|
|
130
|
+
return [...ops.after, ...ops.before.filter((word) => isUnmarkedCheckoutPath(word, ctx))];
|
|
131
|
+
}
|
|
132
|
+
/** True when `spec` selects a directory that holds one of the `.nax` directories (or all of them, from the top). */
|
|
133
|
+
function coversNax(spec, ctx, nax) {
|
|
134
|
+
if (isExclusionSpec(spec))
|
|
135
|
+
return false;
|
|
136
|
+
const magic = /^:(?:\(([^)]*)\)|\/)?(.*)$/s.exec(spec);
|
|
137
|
+
const top = magic !== null && (spec.startsWith(":/") || /(?:^|,)top(?:,|$)/.test(magic[1] ?? ""));
|
|
138
|
+
const body = (magic === null ? spec : magic[2]).replace(/(?:^|\/)\*{1,2}$/, "");
|
|
139
|
+
const bases = top ? [ctx.root] : ctx.frames;
|
|
140
|
+
return bases.some((base) => {
|
|
141
|
+
const dir = resolve(base, body === "" ? "." : body);
|
|
142
|
+
return nax.some((candidate) => candidate === dir || candidate.startsWith(`${dir}${sep}`));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** `--staged` and every unambiguous abbreviation git accepts (`--st`), or a short cluster of only `S`. */
|
|
146
|
+
const STAGED_FLAG = /^(?:--st|-S+$)/;
|
|
147
|
+
/** Flags that change nothing about WHAT is restored. */
|
|
148
|
+
const NEUTRAL_RESTORE_FLAG = /^(?:-q|--quiet|--progress|--no-progress)$/;
|
|
149
|
+
/**
|
|
150
|
+
* `restore --staged` touches only the index. Fails closed: every flag must be `--staged` (or an
|
|
151
|
+
* abbreviation) or a neutral one, so `--wor` / `-SW` / `--source=...` / an unknown flag all count as
|
|
152
|
+
* touching the worktree.
|
|
153
|
+
*/
|
|
154
|
+
function touchesIndexOnly(flags) {
|
|
155
|
+
return (flags.some((f) => STAGED_FLAG.test(f)) && flags.every((f) => STAGED_FLAG.test(f) || NEUTRAL_RESTORE_FLAG.test(f)));
|
|
156
|
+
}
|
|
157
|
+
const treeRefusal = (verb) => `${NAX_OWNED_RUN_STATE_EXPLANATION} A whole-tree \`git ${verb}\` reverts it too. ` +
|
|
158
|
+
`Exclude it: \`git ${verb} -- . ${COVERING_EXCLUSION}\`.`;
|
|
159
|
+
const RESET_REFUSAL = `${NAX_OWNED_RUN_STATE_EXPLANATION} \`git reset --hard\` (and \`--keep\` / \`--merge\`) resets the whole tree, ` +
|
|
160
|
+
"`.nax/` included, and has no pathspec form. " +
|
|
161
|
+
`To discard changes outside it use \`git checkout -- . ${COVERING_EXCLUSION}\`; to unstage use plain \`git reset\`.`;
|
|
162
|
+
const DISCARD_REFUSAL = `${NAX_OWNED_RUN_STATE_EXPLANATION} This command discards or deletes working-tree changes across the whole tree, ` +
|
|
163
|
+
`\`.nax/\` included. Name the files, or exclude it: \`-- . ${COVERING_EXCLUSION}\` (not available for forced checkouts and switches).`;
|
|
164
|
+
function wholeTreeRefusal(verb, ops, ctx) {
|
|
165
|
+
const nax = findNaxDirs(ctx);
|
|
166
|
+
if (nax.length === 0 || (verb === "restore" && touchesIndexOnly(ops.flags)))
|
|
167
|
+
return undefined;
|
|
168
|
+
if (ops.hasSeparator && ops.after.some((spec) => isFullNaxExclusion(spec, ctx)))
|
|
169
|
+
return undefined;
|
|
170
|
+
const specs = ops.hasSeparator ? ops.after : ops.before;
|
|
171
|
+
const exclusionOnly = specs.length > 0 && specs.every(isExclusionSpec);
|
|
172
|
+
return exclusionOnly || specs.some((spec) => coversNax(spec, ctx, nax)) ? treeRefusal(verb) : undefined;
|
|
173
|
+
}
|
|
174
|
+
/** `-f` (alone or in a cluster) or `--force` and its abbreviations. */
|
|
175
|
+
const isForceFlag = (word) => isLongPrefix(word, "--force") || (/^-[A-Za-z]+$/.test(word) && word.includes("f"));
|
|
176
|
+
/** A forced checkout throws local changes away whatever it names. */
|
|
177
|
+
function forcedCheckoutRefusal(verb, ops, ctx) {
|
|
178
|
+
if (verb !== "checkout" || !ops.flags.some(isForceFlag) || findNaxDirs(ctx).length === 0)
|
|
179
|
+
return undefined;
|
|
180
|
+
return ops.hasSeparator && ops.after.some((spec) => isFullNaxExclusion(spec, ctx)) ? undefined : DISCARD_REFUSAL;
|
|
181
|
+
}
|
|
182
|
+
/** The refusal for `git checkout` / `git restore` with these arguments, or undefined. */
|
|
183
|
+
export function revertRefusal(verb, args, ctx) {
|
|
184
|
+
const ops = splitOperands(verb, args);
|
|
185
|
+
// An exclusion NAMES `.nax` to keep it out; it is not an operand that touches it.
|
|
186
|
+
const names = pathOperands(verb, ops, ctx).some((word) => !isExclusionSpec(word) && operandTouchesNax(word, ctx));
|
|
187
|
+
if (names)
|
|
188
|
+
return NAX_OWNED_RUN_STATE_EXPLANATION;
|
|
189
|
+
return wholeTreeRefusal(verb, ops, ctx) ?? forcedCheckoutRefusal(verb, ops, ctx);
|
|
190
|
+
}
|
|
191
|
+
/** `git reset --hard` / `--keep` / `--merge` (any abbreviation git accepts) while a `.nax/` exists. */
|
|
192
|
+
export function resetRefusal(args, ctx) {
|
|
193
|
+
const dangerous = ["--hard", "--keep", "--merge"];
|
|
194
|
+
if (!args.some((word) => dangerous.some((long) => isLongPrefix(word, long))))
|
|
195
|
+
return undefined;
|
|
196
|
+
return findNaxDirs(ctx).length > 0 ? RESET_REFUSAL : undefined;
|
|
197
|
+
}
|
|
198
|
+
const hasFlag = (flags, long, short) => flags.some((f) => isLongPrefix(f, long) || (/^-[A-Za-z]+$/.test(f) && f.includes(short)));
|
|
199
|
+
/**
|
|
200
|
+
* `git clean -n` / `--dry-run`. In a short cluster `-e` takes the rest of the cluster as its value
|
|
201
|
+
* (`-fdenx` is `-f -d -e nx`, a real clean), so only an `n` before any `e` counts.
|
|
202
|
+
*/
|
|
203
|
+
const isCleanDryRun = (flags) => flags.some((f) => isLongPrefix(f, "--dry-run") || (/^-[A-Za-z]+$/.test(f) && f.slice(1).split("e")[0].includes("n")));
|
|
204
|
+
/** Operands (and `--opt=value` values) of a discard verb that name a `.nax` path. */
|
|
205
|
+
function namesNax(ops, ctx) {
|
|
206
|
+
const values = ops.flags.filter((f) => f.includes("=")).map((f) => f.slice(f.indexOf("=") + 1));
|
|
207
|
+
return [...ops.before, ...ops.after, ...values].some((word) => !isExclusionSpec(word) && operandTouchesNax(word, ctx));
|
|
208
|
+
}
|
|
209
|
+
/** Does this switch / clean / checkout-index / read-tree invocation wipe the tree (given no covering exclusion)? */
|
|
210
|
+
function wipesTree(verb, ops, ctx) {
|
|
211
|
+
const f = ops.flags;
|
|
212
|
+
if (verb === "switch")
|
|
213
|
+
return f.some(isForceFlag) || f.some((w) => isLongPrefix(w, "--discard-changes"));
|
|
214
|
+
if (verb === "clean") {
|
|
215
|
+
const covered = ops.hasSeparator && ops.after.some((spec) => isFullNaxExclusion(spec, ctx));
|
|
216
|
+
return !covered && !isCleanDryRun(f);
|
|
217
|
+
}
|
|
218
|
+
if (verb === "checkout-index")
|
|
219
|
+
return hasFlag(f, "--all", "a") && hasFlag(f, "--force", "f");
|
|
220
|
+
if (verb === "read-tree")
|
|
221
|
+
return f.some((w) => isLongPrefix(w, "--reset")) && hasFlag(f, "--update", "u");
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
const NAMED_PATH_VERBS = new Set(["checkout-index", "read-tree", "clean"]);
|
|
225
|
+
/** `switch -f`, `clean`, `checkout-index -a -f`, `read-tree -u --reset`: other ways to wipe the tree. */
|
|
226
|
+
export function discardRefusal(verb, args, ctx) {
|
|
227
|
+
const ops = splitOperands(verb, args);
|
|
228
|
+
if (NAMED_PATH_VERBS.has(verb) && namesNax(ops, ctx))
|
|
229
|
+
return NAX_OWNED_RUN_STATE_EXPLANATION;
|
|
230
|
+
if (findNaxDirs(ctx).length === 0)
|
|
231
|
+
return undefined;
|
|
232
|
+
return wipesTree(verb, ops, ctx) ? DISCARD_REFUSAL : undefined;
|
|
233
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type NaxGuardContext } from "./nax-dirs.ts";
|
|
2
|
+
export type { NaxDirIndex, NaxGuardContext } from "./nax-dirs.ts";
|
|
3
|
+
export { createNaxDirIndex } from "./nax-dirs.ts";
|
|
4
|
+
/**
|
|
5
|
+
* The refusal for one command segment (its words, `git` possibly behind
|
|
6
|
+
* wrappers and global options), or undefined. A segment whose git invocation
|
|
7
|
+
* cannot be located is NOT judged here; see the BACKSTOP in the header. One
|
|
8
|
+
* that CAN be located but met a global option this reader does not know is
|
|
9
|
+
* judged by the structured rules and then, with a `.nax/` present, by the BACKSTOP.
|
|
10
|
+
*/
|
|
11
|
+
export declare function naxStateRefusal(segment: readonly string[], ctx: NaxGuardContext): string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* `naxStateRefusal` over command TEXT: the raw command string in every Bash
|
|
14
|
+
* mode, and the string behind `bash -c` / `eval`. Tracks `cd` itself, applies
|
|
15
|
+
* the structured rules to each segment, and the coarse BACKSTOP (see the
|
|
16
|
+
* header) to segments whose git invocation it could not read.
|
|
17
|
+
*/
|
|
18
|
+
export declare function naxStateRefusalInText(command: string, ctx: NaxGuardContext): string | undefined;
|