polydeukes 0.6.1 → 0.8.0
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/README.ko.md +16 -28
- package/README.md +16 -28
- package/dist/baseline.d.ts +37 -1
- package/dist/baseline.js +68 -1
- package/dist/bin.d.ts +3 -4
- package/dist/bin.js +73 -90
- package/dist/covenant/bash-line.d.ts +130 -0
- package/dist/covenant/bash-line.js +566 -0
- package/dist/covenant/declaration-engine.d.ts +108 -0
- package/dist/covenant/declaration-engine.js +415 -0
- package/dist/covenant/discipline.d.ts +109 -0
- package/dist/covenant/discipline.js +667 -0
- package/dist/covenant/dispatch.d.ts +156 -0
- package/dist/covenant/dispatch.js +250 -0
- package/dist/covenant/extract-steps.d.ts +122 -0
- package/dist/covenant/extract-steps.js +0 -0
- package/dist/covenant/mention.d.ts +117 -0
- package/dist/covenant/mention.js +209 -0
- package/dist/covenant/module.d.ts +25 -0
- package/dist/covenant/module.js +23 -0
- package/dist/covenant/mutation-rules.d.ts +40 -0
- package/dist/covenant/mutation-rules.js +146 -0
- package/dist/covenant/relations.d.ts +46 -0
- package/dist/covenant/relations.js +68 -0
- package/dist/covenant/run-covenant.d.ts +95 -0
- package/dist/covenant/run-covenant.js +164 -0
- package/dist/covenant/self-mod.d.ts +55 -0
- package/dist/covenant/self-mod.js +84 -0
- package/dist/covenant/shell-evidence.d.ts +38 -0
- package/dist/covenant/shell-evidence.js +247 -0
- package/dist/covenant/shell-mod.d.ts +84 -0
- package/dist/covenant/shell-mod.js +213 -0
- package/dist/covenant/supply.d.ts +54 -0
- package/dist/covenant/supply.js +50 -0
- package/dist/covenant/transcript-mod.d.ts +60 -0
- package/dist/covenant/transcript-mod.js +254 -0
- package/dist/covenant/ttl-witness.d.ts +47 -0
- package/dist/covenant/ttl-witness.js +80 -0
- package/dist/covenant-check.d.ts +74 -41
- package/dist/covenant-check.js +372 -129
- package/dist/diff-ir.d.ts +28 -0
- package/dist/diff-ir.js +310 -0
- package/dist/docs/README.ko.md +10 -9
- package/dist/docs/README.md +10 -9
- package/dist/docs/catalog.json +76 -28
- package/dist/docs/concepts/judgment.ko.md +2 -2
- package/dist/docs/concepts/judgment.md +2 -2
- package/dist/docs/how-to/configure-project.ko.md +16 -16
- package/dist/docs/how-to/configure-project.md +15 -12
- package/dist/docs/how-to/connect-surfaces.ko.md +67 -34
- package/dist/docs/how-to/connect-surfaces.md +67 -34
- package/dist/docs/how-to/write-disciplines.ko.md +64 -7
- package/dist/docs/how-to/write-disciplines.md +65 -7
- package/dist/docs/index.json +744 -362
- package/dist/docs/reference/cli/covenant-check.ko.md +112 -65
- package/dist/docs/reference/cli/covenant-check.md +114 -59
- package/dist/docs/reference/cli/docs.ko.md +2 -2
- package/dist/docs/reference/cli/docs.md +2 -2
- package/dist/docs/reference/cli/explain.ko.md +20 -15
- package/dist/docs/reference/cli/explain.md +22 -15
- package/dist/docs/reference/cli/init.ko.md +91 -52
- package/dist/docs/reference/cli/init.md +81 -55
- package/dist/docs/reference/configuration/index.ko.md +182 -71
- package/dist/docs/reference/configuration/index.md +188 -76
- package/dist/docs/reference/packages/adapter-claude-code.ko.md +39 -11
- package/dist/docs/reference/packages/adapter-claude-code.md +40 -14
- package/dist/docs/reference/packages/adapter-codex.ko.md +99 -0
- package/dist/docs/reference/packages/adapter-codex.md +100 -0
- package/dist/docs/reference/packages/adapter-grok.ko.md +81 -0
- package/dist/docs/reference/packages/adapter-grok.md +83 -0
- package/dist/docs/reference/packages/core.ko.md +50 -4
- package/dist/docs/reference/packages/core.md +55 -6
- package/dist/docs/reference/packages/polydeukes.ko.md +139 -77
- package/dist/docs/reference/packages/polydeukes.md +147 -81
- package/dist/docs/reference/packages/sdk-ts.ko.md +164 -0
- package/dist/docs/reference/packages/sdk-ts.md +167 -0
- package/dist/docs/troubleshooting.ko.md +51 -19
- package/dist/docs/troubleshooting.md +53 -17
- package/dist/docs/tutorials/first-judgment.ko.md +6 -4
- package/dist/docs/tutorials/first-judgment.md +6 -4
- package/dist/explain.d.ts +1 -1
- package/dist/explain.js +37 -22
- package/dist/load-config.d.ts +25 -1
- package/dist/load-config.js +21 -3
- package/dist/pre-state-reader.d.ts +1 -1
- package/dist/pre-state-reader.js +1 -1
- package/dist/scaffold-project.d.ts +2 -2
- package/dist/scaffold-project.js +12 -4
- package/dist/schema/polydeukes.schema.json +12 -0
- package/dist/worktree-reader.d.ts +19 -0
- package/dist/worktree-reader.js +30 -0
- package/package.json +5 -18
- package/dist/claude-code-hook.d.ts +0 -79
- package/dist/claude-code-hook.js +0 -372
- package/dist/claude-code.d.ts +0 -6
- package/dist/claude-code.js +0 -6
- package/dist/covenant-module.d.ts +0 -25
- package/dist/covenant-module.js +0 -42
- package/dist/docs/reference/packages/adapter-git.ko.md +0 -101
- package/dist/docs/reference/packages/adapter-git.md +0 -108
- package/dist/docs/reference/packages/covenant.ko.md +0 -115
- package/dist/docs/reference/packages/covenant.md +0 -123
- package/dist/index.d.ts +0 -22
- package/dist/index.js +0 -21
- package/dist/init-claude-code.d.ts +0 -52
- package/dist/init-claude-code.js +0 -586
- package/dist/init-grok.d.ts +0 -51
- package/dist/init-grok.js +0 -242
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `judgeShellModification` — the shell-mod meta-covenant's pure judge (zero I/O).
|
|
3
|
+
*
|
|
4
|
+
* Analyzes the command-line strings of *shell* tool calls (names and arg keys are injected
|
|
5
|
+
* values, never source literals) per simple command: the fixed detection rules catch writes
|
|
6
|
+
* to a protected path, undecidable structures (opaque mentions, opaque write targets) fail
|
|
7
|
+
* closed, the read-only allowlist absolves proven reads, and every other protected-path
|
|
8
|
+
* mention breaks — "mention + unproven = block". It judges only its own axis: a non-shell
|
|
9
|
+
* tool call is upheld, since the tool axis belongs to the self-mod meta-covenant and
|
|
10
|
+
* run-all co-existence depends on that boundary.
|
|
11
|
+
*/
|
|
12
|
+
import { isNestedShellCommand, tokenizeCommandLine } from './bash-line.js';
|
|
13
|
+
import { mentionsPath, untokenizableLineCandidates } from './mention.js';
|
|
14
|
+
import { commandBasename, redirectWriteRule, sedInPlaceRule, teeRule } from './mutation-rules.js';
|
|
15
|
+
import { outcomeFromVerdict, UNJUDGEABLE_OUTCOME } from './run-covenant.js';
|
|
16
|
+
/**
|
|
17
|
+
* Commands proven read-only by shell semantics — the default allowlist. An entry is a
|
|
18
|
+
* leading word sequence; multi-word entries exist because a bare command name (`git`) can
|
|
19
|
+
* front mutating subcommands. Omission errs toward friction, never a hole.
|
|
20
|
+
*
|
|
21
|
+
* An entry must have no way to write a file through its own arguments, since the allowlist
|
|
22
|
+
* vouches for the command head while `matchesReadOnlyEntry` never inspects trailing argv.
|
|
23
|
+
* That is why `git diff`/`git log`/`git show` are absent: all accept `--output=<file>`, a
|
|
24
|
+
* redirect-free truncating write. `git status`/`git grep` reject `--output`, so they stay.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_READ_ONLY_COMMANDS = [
|
|
27
|
+
'cat',
|
|
28
|
+
'head',
|
|
29
|
+
'tail',
|
|
30
|
+
'grep',
|
|
31
|
+
'rg',
|
|
32
|
+
'ls',
|
|
33
|
+
'wc',
|
|
34
|
+
'diff',
|
|
35
|
+
'stat',
|
|
36
|
+
'file',
|
|
37
|
+
'echo',
|
|
38
|
+
'printf',
|
|
39
|
+
'git status',
|
|
40
|
+
'git grep',
|
|
41
|
+
];
|
|
42
|
+
// The rule set is fixed, not injectable: dropping a rule from an assembly would be a
|
|
43
|
+
// detection hole, and no consumer needs a subset.
|
|
44
|
+
const MUTATION_RULES = [redirectWriteRule, teeRule, sedInPlaceRule];
|
|
45
|
+
/**
|
|
46
|
+
* True when the command's leading words match the allowlist entry's word sequence. Exported
|
|
47
|
+
* so the transcript judge's allowlist clause absolves reads by this exact comparison instead
|
|
48
|
+
* of a fork that could drift from it.
|
|
49
|
+
*/
|
|
50
|
+
export function matchesReadOnlyEntry(command, entry) {
|
|
51
|
+
// An empty entry would match every command vacuously (`[].every()` is true) — reject it
|
|
52
|
+
// locally so the covenant does not depend on a distant caller-side filter.
|
|
53
|
+
if (entry.length === 0)
|
|
54
|
+
return false;
|
|
55
|
+
return entry.every((entryWord, k) => {
|
|
56
|
+
const word = command.words[k];
|
|
57
|
+
if (word === undefined || word.opaque)
|
|
58
|
+
return false;
|
|
59
|
+
// The first word is compared by basename (`/bin/cat` is still `cat`); later words verbatim.
|
|
60
|
+
const text = k === 0 ? commandBasename(word) : word.text;
|
|
61
|
+
return text === entryWord;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Judge one simple command. Returns the break reason, or null when the command contributes
|
|
66
|
+
* to uphold. The clause order below is normative: each clause exists to be reached before
|
|
67
|
+
* the next one can absolve. `lineFullyRead` is false when the line carried a span the
|
|
68
|
+
* tokenizer could not read, which withholds the allowlist clause.
|
|
69
|
+
*/
|
|
70
|
+
function judgeCommand(command, protectedPaths, readOnlyEntries, lineFullyRead) {
|
|
71
|
+
// (a) Precise rules: a detected mutation whose target carries a protected path breaks.
|
|
72
|
+
for (const rule of MUTATION_RULES) {
|
|
73
|
+
for (const target of rule.detect(command)) {
|
|
74
|
+
const hit = protectedPaths.find((path) => mentionsPath(target.path, path));
|
|
75
|
+
if (hit !== undefined)
|
|
76
|
+
return `${target.rule} targets protected path ${hit}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// (b) Mention scan over word texts and redirect target texts (heredoc bodies are data —
|
|
80
|
+
// the tokenizer never surfaces them as tokens). No mention: nothing left to judge.
|
|
81
|
+
const tokens = [...command.words, ...command.redirects.map((redirect) => redirect.target)];
|
|
82
|
+
let mentioned;
|
|
83
|
+
let mentionIsOpaque = false;
|
|
84
|
+
for (const token of tokens) {
|
|
85
|
+
const hit = protectedPaths.find((path) => mentionsPath(token.text, path));
|
|
86
|
+
if (hit === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
mentioned ??= hit;
|
|
89
|
+
if (token.opaque)
|
|
90
|
+
mentionIsOpaque = true;
|
|
91
|
+
}
|
|
92
|
+
if (mentioned === undefined)
|
|
93
|
+
return null;
|
|
94
|
+
// (c) A mention inside an opaque token (command substitution, process substitution,
|
|
95
|
+
// expansion, glob) is undecidable, so it breaks rather than passing.
|
|
96
|
+
if (mentionIsOpaque)
|
|
97
|
+
return `protected path ${mentioned} inside an opaque token`;
|
|
98
|
+
// (d) An opaque write target could resolve to the protected path — unprovable, so it
|
|
99
|
+
// breaks even for an allowlisted reader (order over (e) is the invariant).
|
|
100
|
+
if (command.redirects.some((r) => r.operator.includes('>') && r.target.opaque)) {
|
|
101
|
+
return `opaque redirect target alongside protected path ${mentioned}`;
|
|
102
|
+
}
|
|
103
|
+
// (e) Read-only allowlist: a proven read absolves the mention — but a nested shell
|
|
104
|
+
// (`eval`/`sh -c …`) re-parses its string args, so it can never be proven read-only even
|
|
105
|
+
// if it was injected into the allowlist. Its mention falls through to the backstop. A line
|
|
106
|
+
// carrying an unread span is refused the same way: what the scanner never read could be
|
|
107
|
+
// anything, so no head vouches for it.
|
|
108
|
+
const first = command.words[0];
|
|
109
|
+
const firstBasename = first !== undefined ? commandBasename(first) : '';
|
|
110
|
+
if (lineFullyRead &&
|
|
111
|
+
!isNestedShellCommand(firstBasename) &&
|
|
112
|
+
readOnlyEntries.some((entry) => matchesReadOnlyEntry(command, entry))) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
// (f) Backstop — mention + unproven = block.
|
|
116
|
+
return `${first?.text ?? ''} mentions protected path ${mentioned} without read-only proof`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Judge a {@link CovenantInput} against the shell-mod spec (pure).
|
|
120
|
+
*
|
|
121
|
+
* For each `toolCalls[i]` whose `name` exactly equals a non-empty `shellToolNames` entry,
|
|
122
|
+
* every string value under a non-empty `commandArgNames` key is analyzed as a shell line;
|
|
123
|
+
* a shell call with zero such strings breaks (a misassembled arg name must not degrade
|
|
124
|
+
* into universal uphold). A span the tokenizer could not read breaks iff the dequoted span —
|
|
125
|
+
* or one of its shell-metacharacter fragments — mentions a protected path, and is answered
|
|
126
|
+
* before the commands so that a mention only the span can see is named as one. Non-shell
|
|
127
|
+
* calls, `subagentSpawns`, and `userMessages` are never judged.
|
|
128
|
+
*/
|
|
129
|
+
export function judgeShellModification(input, spec) {
|
|
130
|
+
const shellToolNames = spec.shellToolNames.filter((name) => name !== '');
|
|
131
|
+
const commandArgNames = spec.commandArgNames.filter((name) => name !== '');
|
|
132
|
+
const protectedPaths = spec.protectedPaths.filter((path) => path !== '');
|
|
133
|
+
const readOnlyEntries = spec.readOnlyCommands
|
|
134
|
+
.map((entry) => entry.split(/\s+/).filter((word) => word !== ''))
|
|
135
|
+
.filter((entry) => entry.length > 0);
|
|
136
|
+
for (const call of input.toolCalls) {
|
|
137
|
+
if (!shellToolNames.includes(call.name)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const lines = commandArgNames
|
|
141
|
+
.map((name) => call.args?.[name])
|
|
142
|
+
.filter((value) => typeof value === 'string');
|
|
143
|
+
if (lines.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
upheld: false,
|
|
146
|
+
reason: `unjudgeable shell call ${call.name}: no command string under any command-arg name`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
const { commands, unread } = tokenizeCommandLine(line);
|
|
151
|
+
// The conservative treatment applies to the span alone, not the whole line. The shell
|
|
152
|
+
// would still remove quotes and backslash escapes, so a split target like `sr"c"` or
|
|
153
|
+
// `sr\c` becomes `src` on execution — strip both before the segment-match, or the very
|
|
154
|
+
// escaping that stopped the scan defeats the scan that replaces it. Removal may
|
|
155
|
+
// over-join unrelated words, which only ever widens what breaks, never a hole. The
|
|
156
|
+
// fallback-only decomposition then covers the metachar-glued forms (`…/dist;echo x`)
|
|
157
|
+
// that no tokenizer was left to cut apart — narrowing the span must not narrow the
|
|
158
|
+
// extraction.
|
|
159
|
+
for (const span of unread) {
|
|
160
|
+
const candidates = untokenizableLineCandidates(span.text.replace(/['"\\]/g, ''));
|
|
161
|
+
const hit = protectedPaths.find((path) => candidates.some((candidate) => mentionsPath(candidate, path)));
|
|
162
|
+
if (hit !== undefined) {
|
|
163
|
+
return {
|
|
164
|
+
upheld: false,
|
|
165
|
+
reason: `untokenizable command line mentions protected path ${hit}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const command of commands) {
|
|
170
|
+
const reason = judgeCommand(command, protectedPaths, readOnlyEntries, unread.length === 0);
|
|
171
|
+
if (reason !== null)
|
|
172
|
+
return { upheld: false, reason };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { upheld: true };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Build the shell-mod registration. Routing stays path mention; the judgment is the thunk.
|
|
180
|
+
*
|
|
181
|
+
* The misassembly gate lives at the thunk's entry: zero valid entries in any of the three
|
|
182
|
+
* required lists would make {@link judgeShellModification} uphold every call, so it answers
|
|
183
|
+
* the unjudgeable outcome instead, which no enforce level softens. The allowlist is exempt
|
|
184
|
+
* — empty just means stricter.
|
|
185
|
+
*/
|
|
186
|
+
export function shellModRegistration(spec) {
|
|
187
|
+
const judgeSpec = {
|
|
188
|
+
protectedPaths: spec.protectedPaths,
|
|
189
|
+
shellToolNames: spec.shellTools,
|
|
190
|
+
commandArgNames: spec.commandArgs ?? [],
|
|
191
|
+
readOnlyCommands: spec.readOnlyCommands ?? DEFAULT_READ_ONLY_COMMANDS,
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
label: 'shell-mod',
|
|
195
|
+
protectedPaths: spec.protectedPaths,
|
|
196
|
+
body: async (input) => {
|
|
197
|
+
if (judgeSpec.protectedPaths.filter((path) => path !== '').length === 0 ||
|
|
198
|
+
judgeSpec.shellToolNames.filter((name) => name !== '').length === 0 ||
|
|
199
|
+
judgeSpec.commandArgNames.filter((arg) => arg !== '').length === 0) {
|
|
200
|
+
return UNJUDGEABLE_OUTCOME;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
return outcomeFromVerdict(judgeShellModification(input, judgeSpec));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Structurally unjudgeable input that passed parseInput (which validates the
|
|
207
|
+
// collection shapes, not the element ones): cannot judge means block.
|
|
208
|
+
return UNJUDGEABLE_OUTCOME;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
...(spec.witness !== undefined ? { witness: spec.witness } : {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The supply layer — what a judgment's world is made of before any judgment runs.
|
|
3
|
+
*
|
|
4
|
+
* Two verbs: `planSources` folds the registrations' `sources` bindings into a path list and
|
|
5
|
+
* a channel-kind list, and `supplySources` fills each through its own injected reader. The
|
|
6
|
+
* reading itself belongs to the composition root, which is the only place that knows how its
|
|
7
|
+
* surface observes the tree; this module opens no file and spawns no process.
|
|
8
|
+
*/
|
|
9
|
+
import type { ChannelReader, SourceReader } from '@polydeukes/core';
|
|
10
|
+
import type { CovenantRegistration } from './dispatch.ts';
|
|
11
|
+
/** `planSources` input — the registration table the judgment will run. */
|
|
12
|
+
export type PlanSourcesSpec = {
|
|
13
|
+
registrations: readonly CovenantRegistration[];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* What the registrations named, in registration then declaration order, deduplicated per
|
|
17
|
+
* axis: `files` are repo-relative paths, `channels` the channel KINDS the bindings name —
|
|
18
|
+
* two bindings on one kind are one reading, and which name each carries is the merge's own.
|
|
19
|
+
*/
|
|
20
|
+
export type SourcePlan = {
|
|
21
|
+
files: readonly string[];
|
|
22
|
+
channels: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* `supplySources` input — a plan and the surface's readers.
|
|
26
|
+
*
|
|
27
|
+
* Both readers answer `undefined` for something that is not there and throw for every other
|
|
28
|
+
* failure, so a permission error reaches the root's fail-closed path instead of passing
|
|
29
|
+
* for an absence the declaration's `supply` policy would wave through. An ABSENT
|
|
30
|
+
* `readChannel` is the change-set surface, which has no session: every channel is absent.
|
|
31
|
+
*/
|
|
32
|
+
export type SupplySourcesSpec = {
|
|
33
|
+
plan: SourcePlan;
|
|
34
|
+
read: SourceReader;
|
|
35
|
+
readChannel?: ChannelReader;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* What the readers gave back, keyed by the planned path and the planned kind. Anything a
|
|
39
|
+
* reader could not answer has no key at all — a key holding `undefined` would pass the
|
|
40
|
+
* engine's presence test as a supply that never happened.
|
|
41
|
+
*/
|
|
42
|
+
export type SuppliedSources = {
|
|
43
|
+
files: Record<string, string>;
|
|
44
|
+
channels: Record<string, string>;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The paths and channel kinds the registrations name, first occurrence winning.
|
|
48
|
+
*
|
|
49
|
+
* Order is the plan's contract: the supplied result and the witnesses built on it keep it,
|
|
50
|
+
* which is what lets two surfaces reach the same first witness.
|
|
51
|
+
*/
|
|
52
|
+
export declare function planSources(spec: PlanSourcesSpec): SourcePlan;
|
|
53
|
+
/** Read each planned path and kind once, in plan order, keeping only what came back. */
|
|
54
|
+
export declare function supplySources(spec: SupplySourcesSpec): SuppliedSources;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The supply layer — what a judgment's world is made of before any judgment runs.
|
|
3
|
+
*
|
|
4
|
+
* Two verbs: `planSources` folds the registrations' `sources` bindings into a path list and
|
|
5
|
+
* a channel-kind list, and `supplySources` fills each through its own injected reader. The
|
|
6
|
+
* reading itself belongs to the composition root, which is the only place that knows how its
|
|
7
|
+
* surface observes the tree; this module opens no file and spawns no process.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The paths and channel kinds the registrations name, first occurrence winning.
|
|
11
|
+
*
|
|
12
|
+
* Order is the plan's contract: the supplied result and the witnesses built on it keep it,
|
|
13
|
+
* which is what lets two surfaces reach the same first witness.
|
|
14
|
+
*/
|
|
15
|
+
export function planSources(spec) {
|
|
16
|
+
const files = [];
|
|
17
|
+
const channels = [];
|
|
18
|
+
for (const registration of spec.registrations) {
|
|
19
|
+
for (const binding of registration.sources ?? []) {
|
|
20
|
+
// A transcript is neither a path nor a channel kind: the surface hands the session
|
|
21
|
+
// to assembly directly, so there is nothing here for a reader to fetch.
|
|
22
|
+
if ('transcript' in binding)
|
|
23
|
+
continue;
|
|
24
|
+
if ('sidecar' in binding) {
|
|
25
|
+
if (!channels.includes('sidecar'))
|
|
26
|
+
channels.push('sidecar');
|
|
27
|
+
}
|
|
28
|
+
else if (!files.includes(binding.file)) {
|
|
29
|
+
files.push(binding.file);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { files, channels };
|
|
34
|
+
}
|
|
35
|
+
/** Read each planned path and kind once, in plan order, keeping only what came back. */
|
|
36
|
+
export function supplySources(spec) {
|
|
37
|
+
const files = {};
|
|
38
|
+
for (const path of spec.plan.files) {
|
|
39
|
+
const text = spec.read(path);
|
|
40
|
+
if (text !== undefined)
|
|
41
|
+
files[path] = text;
|
|
42
|
+
}
|
|
43
|
+
const channels = {};
|
|
44
|
+
for (const kind of spec.plan.channels) {
|
|
45
|
+
const text = spec.readChannel?.(kind);
|
|
46
|
+
if (text !== undefined)
|
|
47
|
+
channels[kind] = text;
|
|
48
|
+
}
|
|
49
|
+
return { files, channels };
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `judgeTranscriptModification` — the transcript-mod covenant's pure judge (zero I/O) plus
|
|
3
|
+
* the registration factory that routes on it.
|
|
4
|
+
*
|
|
5
|
+
* The transcript is judged by whole-path equality on one file, never as an ancestor, so
|
|
6
|
+
* home-directory spellings (`~`, `$HOME`, `${HOME}`, `~<user>`) are closed by the injected
|
|
7
|
+
* `home` value rather than by protecting home itself. Out-of-repo ancestor destruction is
|
|
8
|
+
* outside observation scope and passes here by design. The Bash axis follows shell-mod's
|
|
9
|
+
* ladder without the opaque-mention clause (reading a session never needs a witness); the
|
|
10
|
+
* tool axis judges proven `fileChange` targets and falls back to the `args` traversal.
|
|
11
|
+
*/
|
|
12
|
+
import type { CovenantInput, CovenantVerdict } from '@polydeukes/core';
|
|
13
|
+
import type { CovenantRegistration, MetaCovenantRegistration } from './dispatch.ts';
|
|
14
|
+
/**
|
|
15
|
+
* `TranscriptModificationSpec` — the injected axes of the judge. Empty-string entries in
|
|
16
|
+
* every list are ignored.
|
|
17
|
+
*/
|
|
18
|
+
export type TranscriptModificationSpec = {
|
|
19
|
+
/** the one file this covenant owns */
|
|
20
|
+
transcriptPath: string;
|
|
21
|
+
/** directory whose spellings are closed; absent or malformed leaves them open */
|
|
22
|
+
home?: string;
|
|
23
|
+
shellToolNames: string[];
|
|
24
|
+
commandArgNames: string[];
|
|
25
|
+
mutatingToolNames: string[];
|
|
26
|
+
/** read-only allowlist entries in shell-mod's format */
|
|
27
|
+
readOnlyCommands: string[];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Judge a {@link CovenantInput} against the transcript-mod spec (pure).
|
|
31
|
+
*
|
|
32
|
+
* Shell-tool calls are judged on the Bash axis per simple command, mutating-tool calls on
|
|
33
|
+
* the tool axis; every other call is never judged. A degenerate `transcriptPath` upholds
|
|
34
|
+
* everything.
|
|
35
|
+
*
|
|
36
|
+
* @param input - The call set to judge
|
|
37
|
+
* @param spec - The injected axes; empty strings are ignored
|
|
38
|
+
* @returns A break naming what touched the transcript, or an uphold
|
|
39
|
+
*/
|
|
40
|
+
export declare function judgeTranscriptModification(input: CovenantInput, spec: TranscriptModificationSpec): CovenantVerdict;
|
|
41
|
+
/** `TranscriptModRegistrationSpec` — the assembly values baked into the registration. */
|
|
42
|
+
export type TranscriptModRegistrationSpec = {
|
|
43
|
+
transcriptPath: string;
|
|
44
|
+
home?: string;
|
|
45
|
+
shellTools: string[];
|
|
46
|
+
commandArgs: string[];
|
|
47
|
+
mutatingTools: string[];
|
|
48
|
+
witness?: CovenantRegistration['witness'];
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Build the transcript-mod registration. Routing is the judge itself as a `matches`
|
|
52
|
+
* predicate — `protectedPaths` stays empty so no home ancestor re-enters path-mention
|
|
53
|
+
* routing — and the telemetry subject is the canonical absolute path.
|
|
54
|
+
*
|
|
55
|
+
* The misassembly gate the other meta-covenants carry applies here too: with any of the
|
|
56
|
+
* three axis lists empty the judge can never see a call, so routing on it would go silently
|
|
57
|
+
* inert — the universal-uphold shape the gate exists to refuse. Such a spec routes every
|
|
58
|
+
* call instead and answers the unjudgeable outcome, which no enforce level softens.
|
|
59
|
+
*/
|
|
60
|
+
export declare function transcriptModRegistration(spec: TranscriptModRegistrationSpec): MetaCovenantRegistration;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `judgeTranscriptModification` — the transcript-mod covenant's pure judge (zero I/O) plus
|
|
3
|
+
* the registration factory that routes on it.
|
|
4
|
+
*
|
|
5
|
+
* The transcript is judged by whole-path equality on one file, never as an ancestor, so
|
|
6
|
+
* home-directory spellings (`~`, `$HOME`, `${HOME}`, `~<user>`) are closed by the injected
|
|
7
|
+
* `home` value rather than by protecting home itself. Out-of-repo ancestor destruction is
|
|
8
|
+
* outside observation scope and passes here by design. The Bash axis follows shell-mod's
|
|
9
|
+
* ladder without the opaque-mention clause (reading a session never needs a witness); the
|
|
10
|
+
* tool axis judges proven `fileChange` targets and falls back to the `args` traversal.
|
|
11
|
+
*/
|
|
12
|
+
import { isNestedShellCommand, tokenizeCommandLine } from './bash-line.js';
|
|
13
|
+
import { pathCandidates, pathSegments, provenChangePath, resolveDotSegments, someStringValue, untokenizableLineCandidates, } from './mention.js';
|
|
14
|
+
import { commandBasename, redirectWriteRule, sedInPlaceRule, teeRule } from './mutation-rules.js';
|
|
15
|
+
import { outcomeFromVerdict, UNJUDGEABLE_OUTCOME } from './run-covenant.js';
|
|
16
|
+
import { DEFAULT_READ_ONLY_COMMANDS, matchesReadOnlyEntry } from './shell-mod.js';
|
|
17
|
+
// The rule set is fixed, assembled exactly as shell-mod assembles it: dropping one would be
|
|
18
|
+
// a detection hole, and the two judges must not diverge on what counts as a write.
|
|
19
|
+
const MUTATION_RULES = [redirectWriteRule, teeRule, sedInPlaceRule];
|
|
20
|
+
/** True iff two segment runs are the same path — length and every segment's text. */
|
|
21
|
+
function segmentsEqual(a, b) {
|
|
22
|
+
return a.length === b.length && a.every((segment, i) => segment === b[i]);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Normalize the injected home value, or return `null` when it is not an absolute path
|
|
26
|
+
* naming a directory — expanding such a value would manufacture matches (`''` turns `~/x`
|
|
27
|
+
* into `/x`).
|
|
28
|
+
*/
|
|
29
|
+
function normalizeHome(home) {
|
|
30
|
+
if (home === undefined || !home.startsWith('/'))
|
|
31
|
+
return null;
|
|
32
|
+
const path = home.replace(/\/+$/, '');
|
|
33
|
+
const user = pathSegments(path).at(-1);
|
|
34
|
+
if (user === undefined)
|
|
35
|
+
return null;
|
|
36
|
+
return { path, user };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the transcript's axes, or return `null` for a degenerate `transcriptPath` (the
|
|
40
|
+
* covenant goes inert rather than matching everything). Home forms are recognized only when
|
|
41
|
+
* the transcript lives under the given home.
|
|
42
|
+
*/
|
|
43
|
+
function resolveTranscript(spec) {
|
|
44
|
+
const segments = pathSegments(spec.transcriptPath);
|
|
45
|
+
if (!segments.some((segment) => segment !== '.'))
|
|
46
|
+
return null;
|
|
47
|
+
const home = normalizeHome(spec.home);
|
|
48
|
+
const underHome = home !== null && spec.transcriptPath.startsWith(`${home.path}/`);
|
|
49
|
+
return {
|
|
50
|
+
target: resolveDotSegments(segments),
|
|
51
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional shell expansion spelling
|
|
52
|
+
homePrefixes: underHome && home !== null ? ['~', '$HOME', '${HOME}', `~${home.user}`] : [],
|
|
53
|
+
homeSegments: home === null ? [] : pathSegments(home.path),
|
|
54
|
+
path: spec.transcriptPath,
|
|
55
|
+
readOnlyEntries: spec.readOnlyCommands
|
|
56
|
+
.map((entry) => entry.split(/\s+/).filter((word) => word !== ''))
|
|
57
|
+
.filter((entry) => entry.length > 0),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* True iff a path candidate names the transcript by whole-path equality. A home prefix is
|
|
62
|
+
* substituted before dots are resolved, matching the shell's expansion order.
|
|
63
|
+
*/
|
|
64
|
+
function namesTranscript(candidate, transcript) {
|
|
65
|
+
const segments = pathSegments(candidate);
|
|
66
|
+
const head = segments[0];
|
|
67
|
+
const expanded = head !== undefined && transcript.homePrefixes.includes(head)
|
|
68
|
+
? [...transcript.homeSegments, ...segments.slice(1)]
|
|
69
|
+
: segments;
|
|
70
|
+
return segmentsEqual(resolveDotSegments(expanded), transcript.target);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The path forms one candidate can carry: itself, plus the rooted suffix behind a glued
|
|
74
|
+
* prefix (`curl -o/abs`, `host:/abs`, `>>/abs`), which whole-path equality would otherwise
|
|
75
|
+
* miss.
|
|
76
|
+
*/
|
|
77
|
+
function pathForms(candidate) {
|
|
78
|
+
if (/^[/~$]/.test(candidate))
|
|
79
|
+
return [candidate];
|
|
80
|
+
const rooted = /[/~$]/.exec(candidate);
|
|
81
|
+
return rooted === null ? [candidate] : [candidate, candidate.slice(rooted.index)];
|
|
82
|
+
}
|
|
83
|
+
/** True iff any path candidate inside one token names the transcript. */
|
|
84
|
+
function tokenNamesTranscript(token, transcript) {
|
|
85
|
+
return pathCandidates(token)
|
|
86
|
+
.flatMap(pathForms)
|
|
87
|
+
.some((candidate) => namesTranscript(candidate, transcript));
|
|
88
|
+
}
|
|
89
|
+
/** True iff any string value inside `value`, at any depth, names the transcript. */
|
|
90
|
+
function argsNameTranscript(value, transcript) {
|
|
91
|
+
return someStringValue(value, (token) => tokenNamesTranscript(token, transcript));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Judge one simple command. Returns the break reason, or null when the command contributes
|
|
95
|
+
* to uphold. Clause `(d)` must stay ahead of `(e)`: an opaque write target is unprovable, so
|
|
96
|
+
* it has to break before the allowlist gets a chance to absolve it. The letters skip `(c)`
|
|
97
|
+
* because this ladder has no opaque-mention clause, matching the shell ladder's numbering
|
|
98
|
+
* rather than closing the gap. `lineFullyRead` is false when the line carried a span the
|
|
99
|
+
* tokenizer could not read, which withholds the allowlist clause.
|
|
100
|
+
*/
|
|
101
|
+
function judgeCommand(command, transcript, lineFullyRead) {
|
|
102
|
+
// (a) Precise rules: a detected mutation whose target is the transcript breaks.
|
|
103
|
+
for (const rule of MUTATION_RULES) {
|
|
104
|
+
for (const target of rule.detect(command)) {
|
|
105
|
+
if (tokenNamesTranscript(target.path, transcript)) {
|
|
106
|
+
return `${target.rule} targets the session transcript ${transcript.path}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// (b) Mention scan over word texts and redirect target texts. Opaque tokens are read too:
|
|
111
|
+
// this ladder has no opaque-mention clause, so their text must still register a mention for
|
|
112
|
+
// the backstop to answer. No mention: nothing left to judge.
|
|
113
|
+
const tokens = [...command.words, ...command.redirects.map((redirect) => redirect.target)];
|
|
114
|
+
if (!tokens.some((token) => tokenNamesTranscript(token.text, transcript)))
|
|
115
|
+
return null;
|
|
116
|
+
// (d) An opaque write target could resolve to the transcript itself — unprovable, so it
|
|
117
|
+
// breaks even for an allowlisted reader (order over (e) is the invariant).
|
|
118
|
+
if (command.redirects.some((r) => r.operator.includes('>') && r.target.opaque)) {
|
|
119
|
+
return `opaque redirect target alongside the session transcript ${transcript.path}`;
|
|
120
|
+
}
|
|
121
|
+
// (e) Read-only allowlist: a proven read absolves the mention, in every spelling — but a
|
|
122
|
+
// nested shell (`eval`/`sh -c …`) re-parses its string args, so it is never provably a read.
|
|
123
|
+
// A line carrying an unread span is refused the same way: reading the session is free, but
|
|
124
|
+
// only on a line we finished reading.
|
|
125
|
+
const first = command.words[0];
|
|
126
|
+
const firstBasename = first !== undefined ? commandBasename(first) : '';
|
|
127
|
+
if (lineFullyRead &&
|
|
128
|
+
!isNestedShellCommand(firstBasename) &&
|
|
129
|
+
transcript.readOnlyEntries.some((entry) => matchesReadOnlyEntry(command, entry))) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
// (f) Backstop — mention + unproven = block.
|
|
133
|
+
return `${first?.text ?? ''} names the session transcript ${transcript.path} without read-only proof`;
|
|
134
|
+
}
|
|
135
|
+
/** Judge one shell-tool call's command lines. Returns the break reason, or null. */
|
|
136
|
+
function judgeShellCall(call, commandArgNames, transcript) {
|
|
137
|
+
// A shell call with no command string is not judged: this judge is also the router, so
|
|
138
|
+
// breaking here would record the transcript as the subject of a call that never named it.
|
|
139
|
+
const lines = commandArgNames
|
|
140
|
+
.map((name) => call.args?.[name])
|
|
141
|
+
.filter((value) => typeof value === 'string');
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
const { commands, unread } = tokenizeCommandLine(line);
|
|
144
|
+
// An unread span gets the conservative treatment: quotes and escapes are stripped as
|
|
145
|
+
// the shell would, and metachar-glued spellings are decomposed.
|
|
146
|
+
for (const span of unread) {
|
|
147
|
+
const candidates = untokenizableLineCandidates(span.text.replace(/['"\\]/g, ''));
|
|
148
|
+
if (candidates.some((candidate) => tokenNamesTranscript(candidate, transcript))) {
|
|
149
|
+
return `untokenizable command line names the session transcript ${transcript.path}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const command of commands) {
|
|
153
|
+
const reason = judgeCommand(command, transcript, unread.length === 0);
|
|
154
|
+
if (reason !== null)
|
|
155
|
+
return reason;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
/** Judge one mutating tool call on its proven target, else on its `args`. Returns the break reason, or null. */
|
|
161
|
+
function judgeMutatingCall(call, transcript) {
|
|
162
|
+
const changePath = provenChangePath(call);
|
|
163
|
+
if (changePath !== null) {
|
|
164
|
+
return namesTranscript(changePath, transcript)
|
|
165
|
+
? `${call.name} would modify the session transcript ${changePath}`
|
|
166
|
+
: null;
|
|
167
|
+
}
|
|
168
|
+
// No target is proven here, so the reason reports the observation, not a write.
|
|
169
|
+
return argsNameTranscript(call.args, transcript)
|
|
170
|
+
? `${call.name} names the session transcript ${transcript.path} with no proven target`
|
|
171
|
+
: null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Judge a {@link CovenantInput} against the transcript-mod spec (pure).
|
|
175
|
+
*
|
|
176
|
+
* Shell-tool calls are judged on the Bash axis per simple command, mutating-tool calls on
|
|
177
|
+
* the tool axis; every other call is never judged. A degenerate `transcriptPath` upholds
|
|
178
|
+
* everything.
|
|
179
|
+
*
|
|
180
|
+
* @param input - The call set to judge
|
|
181
|
+
* @param spec - The injected axes; empty strings are ignored
|
|
182
|
+
* @returns A break naming what touched the transcript, or an uphold
|
|
183
|
+
*/
|
|
184
|
+
export function judgeTranscriptModification(input, spec) {
|
|
185
|
+
const transcript = resolveTranscript(spec);
|
|
186
|
+
if (transcript === null) {
|
|
187
|
+
return { upheld: true };
|
|
188
|
+
}
|
|
189
|
+
const shellToolNames = spec.shellToolNames.filter((name) => name !== '');
|
|
190
|
+
const commandArgNames = spec.commandArgNames.filter((name) => name !== '');
|
|
191
|
+
const mutatingToolNames = spec.mutatingToolNames.filter((name) => name !== '');
|
|
192
|
+
for (const call of input.toolCalls) {
|
|
193
|
+
if (shellToolNames.includes(call.name)) {
|
|
194
|
+
const reason = judgeShellCall(call, commandArgNames, transcript);
|
|
195
|
+
if (reason !== null)
|
|
196
|
+
return { upheld: false, reason };
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (mutatingToolNames.includes(call.name)) {
|
|
200
|
+
const reason = judgeMutatingCall(call, transcript);
|
|
201
|
+
if (reason !== null)
|
|
202
|
+
return { upheld: false, reason };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { upheld: true };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Build the transcript-mod registration. Routing is the judge itself as a `matches`
|
|
209
|
+
* predicate — `protectedPaths` stays empty so no home ancestor re-enters path-mention
|
|
210
|
+
* routing — and the telemetry subject is the canonical absolute path.
|
|
211
|
+
*
|
|
212
|
+
* The misassembly gate the other meta-covenants carry applies here too: with any of the
|
|
213
|
+
* three axis lists empty the judge can never see a call, so routing on it would go silently
|
|
214
|
+
* inert — the universal-uphold shape the gate exists to refuse. Such a spec routes every
|
|
215
|
+
* call instead and answers the unjudgeable outcome, which no enforce level softens.
|
|
216
|
+
*/
|
|
217
|
+
export function transcriptModRegistration(spec) {
|
|
218
|
+
const judgeSpec = {
|
|
219
|
+
transcriptPath: spec.transcriptPath,
|
|
220
|
+
home: spec.home,
|
|
221
|
+
shellToolNames: spec.shellTools,
|
|
222
|
+
commandArgNames: spec.commandArgs,
|
|
223
|
+
mutatingToolNames: spec.mutatingTools,
|
|
224
|
+
readOnlyCommands: DEFAULT_READ_ONLY_COMMANDS,
|
|
225
|
+
};
|
|
226
|
+
const axesEmpty = judgeSpec.mutatingToolNames.filter((name) => name !== '').length === 0 ||
|
|
227
|
+
judgeSpec.shellToolNames.filter((name) => name !== '').length === 0 ||
|
|
228
|
+
judgeSpec.commandArgNames.filter((arg) => arg !== '').length === 0;
|
|
229
|
+
if (axesEmpty) {
|
|
230
|
+
return {
|
|
231
|
+
label: 'transcript-mod',
|
|
232
|
+
protectedPaths: [],
|
|
233
|
+
matches: () => spec.transcriptPath,
|
|
234
|
+
body: async () => UNJUDGEABLE_OUTCOME,
|
|
235
|
+
...(spec.witness !== undefined ? { witness: spec.witness } : {}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
label: 'transcript-mod',
|
|
240
|
+
protectedPaths: [],
|
|
241
|
+
matches: (input) => judgeTranscriptModification(input, judgeSpec).upheld ? null : spec.transcriptPath,
|
|
242
|
+
body: async (input) => {
|
|
243
|
+
try {
|
|
244
|
+
return outcomeFromVerdict(judgeTranscriptModification(input, judgeSpec));
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Structurally unjudgeable input that passed parseInput (which validates the
|
|
248
|
+
// collection shapes, not the element ones): cannot judge means block.
|
|
249
|
+
return UNJUDGEABLE_OUTCOME;
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
...(spec.witness !== undefined ? { witness: spec.witness } : {}),
|
|
253
|
+
};
|
|
254
|
+
}
|