polydeukes 0.8.0 → 0.9.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.
@@ -4,7 +4,7 @@
4
4
  * Analyzes the command-line strings of *shell* tool calls (names and arg keys are injected
5
5
  * values, never source literals) per simple command: the fixed detection rules catch writes
6
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
7
+ * closed, read-only proof absolves safe reads, and every other protected-path
8
8
  * mention breaks — "mention + unproven = block". It judges only its own axis: a non-shell
9
9
  * tool call is upheld, since the tool axis belongs to the self-mod meta-covenant and
10
10
  * run-all co-existence depends on that boundary.
@@ -18,7 +18,7 @@ import type { CovenantRegistration, MetaCovenantRegistration } from './dispatch.
18
18
  * `protectedPaths` are literal path strings; `shellToolNames` are the tool names whose
19
19
  * calls carry shell lines; `commandArgNames` are the `args` keys those lines live under;
20
20
  * `readOnlyCommands` are allowlist entries — space-separated word sequences (`'cat'`,
21
- * `'git diff'`). Empty-string entries in every list are ignored (an unguarded `''` would
21
+ * `'git status'`). Empty-string entries in every list are ignored (an unguarded `''` would
22
22
  * match every path / tool / arg / command).
23
23
  */
24
24
  export type ShellModificationSpec = {
@@ -38,6 +38,16 @@ export type ShellModificationSpec = {
38
38
  * redirect-free truncating write. `git status`/`git grep` reject `--output`, so they stay.
39
39
  */
40
40
  export declare const DEFAULT_READ_ONLY_COMMANDS: string[];
41
+ /**
42
+ * True when a configured allowlist has the same effective entries as the shipped default.
43
+ * Conditional readers belong to that default contract and stay disabled for replacements.
44
+ */
45
+ export declare function usesDefaultReadOnlyCommands(commands: string[]): boolean;
46
+ /**
47
+ * Prove the finite argument-sensitive readers that cannot be represented by a leading-word
48
+ * allowlist. Every word must be transparent because these readers inspect their later words.
49
+ */
50
+ export declare function matchesConditionalReadOnlyCommand(command: SimpleCommand): boolean;
41
51
  /**
42
52
  * True when the command's leading words match the allowlist entry's word sequence. Exported
43
53
  * so the transcript judge's allowlist clause absolves reads by this exact comparison instead
@@ -59,8 +69,9 @@ export declare function judgeShellModification(input: CovenantInput, spec: Shell
59
69
  /**
60
70
  * `ShellModRegistrationSpec` — the assembly values baked into the registration. The call
61
71
  * set is not among them: the dispatcher supplies it to the judge at call time.
62
- * `readOnlyCommands` REPLACES {@link DEFAULT_READ_ONLY_COMMANDS} when given — no merge,
63
- * since an assembly wanting to extend the default spreads the constant.
72
+ * `readOnlyCommands` REPLACES {@link DEFAULT_READ_ONLY_COMMANDS} when given — no merge.
73
+ * Any replacement, including a superset made by spreading the default, disables the finite
74
+ * argument-sensitive readers because their proof belongs to the exact shipped default.
64
75
  */
65
76
  export type ShellModRegistrationSpec = {
66
77
  protectedPaths: string[];
@@ -4,7 +4,7 @@
4
4
  * Analyzes the command-line strings of *shell* tool calls (names and arg keys are injected
5
5
  * values, never source literals) per simple command: the fixed detection rules catch writes
6
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
7
+ * closed, read-only proof absolves safe reads, and every other protected-path
8
8
  * mention breaks — "mention + unproven = block". It judges only its own axis: a non-shell
9
9
  * tool call is upheld, since the tool axis belongs to the self-mod meta-covenant and
10
10
  * run-all co-existence depends on that boundary.
@@ -39,6 +39,58 @@ export const DEFAULT_READ_ONLY_COMMANDS = [
39
39
  'git status',
40
40
  'git grep',
41
41
  ];
42
+ const FIND_WRITE_OR_EXECUTE_ACTIONS = new Set([
43
+ '-delete',
44
+ '-exec',
45
+ '-execdir',
46
+ '-ok',
47
+ '-okdir',
48
+ '-fprint',
49
+ '-fprint0',
50
+ '-fprintf',
51
+ '-fls',
52
+ ]);
53
+ function normalizedReadOnlyCommands(commands) {
54
+ return [
55
+ ...new Set(commands
56
+ .map((entry) => entry
57
+ .split(/\s+/)
58
+ .filter((word) => word !== '')
59
+ .join(' '))
60
+ .filter((entry) => entry !== '')),
61
+ ].sort();
62
+ }
63
+ /**
64
+ * True when a configured allowlist has the same effective entries as the shipped default.
65
+ * Conditional readers belong to that default contract and stay disabled for replacements.
66
+ */
67
+ export function usesDefaultReadOnlyCommands(commands) {
68
+ const configured = normalizedReadOnlyCommands(commands);
69
+ const shipped = normalizedReadOnlyCommands(DEFAULT_READ_ONLY_COMMANDS);
70
+ return (configured.length === shipped.length && configured.every((entry, i) => entry === shipped[i]));
71
+ }
72
+ /**
73
+ * Prove the finite argument-sensitive readers that cannot be represented by a leading-word
74
+ * allowlist. Every word must be transparent because these readers inspect their later words.
75
+ */
76
+ export function matchesConditionalReadOnlyCommand(command) {
77
+ if (command.words.length === 0 || command.words.some((word) => word.opaque))
78
+ return false;
79
+ const name = commandBasename(command.words[0]);
80
+ if (name === 'git')
81
+ return command.words[1]?.text === 'ls-files';
82
+ if (name === 'find') {
83
+ return !command.words.some((word) => FIND_WRITE_OR_EXECUTE_ACTIONS.has(word.text));
84
+ }
85
+ if (name !== 'sed')
86
+ return false;
87
+ const script = command.words[2]?.text;
88
+ return (command.words[1]?.text === '-n' &&
89
+ script !== undefined &&
90
+ /^\d+(?:,\d+)?p$/.test(script) &&
91
+ command.words.length > 3 &&
92
+ command.words.slice(3).every((word) => !word.text.startsWith('-')));
93
+ }
42
94
  // The rule set is fixed, not injectable: dropping a rule from an assembly would be a
43
95
  // detection hole, and no consumer needs a subset.
44
96
  const MUTATION_RULES = [redirectWriteRule, teeRule, sedInPlaceRule];
@@ -65,9 +117,9 @@ export function matchesReadOnlyEntry(command, entry) {
65
117
  * Judge one simple command. Returns the break reason, or null when the command contributes
66
118
  * to uphold. The clause order below is normative: each clause exists to be reached before
67
119
  * 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.
120
+ * tokenizer could not read, which withholds read-only proof.
69
121
  */
70
- function judgeCommand(command, protectedPaths, readOnlyEntries, lineFullyRead) {
122
+ function judgeCommand(command, protectedPaths, readOnlyEntries, conditionalReadersEnabled, lineFullyRead) {
71
123
  // (a) Precise rules: a detected mutation whose target carries a protected path breaks.
72
124
  for (const rule of MUTATION_RULES) {
73
125
  for (const target of rule.detect(command)) {
@@ -100,16 +152,18 @@ function judgeCommand(command, protectedPaths, readOnlyEntries, lineFullyRead) {
100
152
  if (command.redirects.some((r) => r.operator.includes('>') && r.target.opaque)) {
101
153
  return `opaque redirect target alongside protected path ${mentioned}`;
102
154
  }
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
155
+ // (e) Read-only proof: the allowlist or a finite argument-sensitive reader absolves the
156
+ // mention. A nested shell (`eval`/`sh -c …`) re-parses its string args, so it can never be
157
+ // proven read-only even if it was injected into the allowlist. Its mention falls through
158
+ // to the backstop. A line
106
159
  // carrying an unread span is refused the same way: what the scanner never read could be
107
160
  // anything, so no head vouches for it.
108
161
  const first = command.words[0];
109
162
  const firstBasename = first !== undefined ? commandBasename(first) : '';
110
163
  if (lineFullyRead &&
111
164
  !isNestedShellCommand(firstBasename) &&
112
- readOnlyEntries.some((entry) => matchesReadOnlyEntry(command, entry))) {
165
+ (readOnlyEntries.some((entry) => matchesReadOnlyEntry(command, entry)) ||
166
+ (conditionalReadersEnabled && matchesConditionalReadOnlyCommand(command)))) {
113
167
  return null;
114
168
  }
115
169
  // (f) Backstop — mention + unproven = block.
@@ -133,6 +187,7 @@ export function judgeShellModification(input, spec) {
133
187
  const readOnlyEntries = spec.readOnlyCommands
134
188
  .map((entry) => entry.split(/\s+/).filter((word) => word !== ''))
135
189
  .filter((entry) => entry.length > 0);
190
+ const conditionalReadersEnabled = usesDefaultReadOnlyCommands(spec.readOnlyCommands);
136
191
  for (const call of input.toolCalls) {
137
192
  if (!shellToolNames.includes(call.name)) {
138
193
  continue;
@@ -167,7 +222,7 @@ export function judgeShellModification(input, spec) {
167
222
  }
168
223
  }
169
224
  for (const command of commands) {
170
- const reason = judgeCommand(command, protectedPaths, readOnlyEntries, unread.length === 0);
225
+ const reason = judgeCommand(command, protectedPaths, readOnlyEntries, conditionalReadersEnabled, unread.length === 0);
171
226
  if (reason !== null)
172
227
  return { upheld: false, reason };
173
228
  }
@@ -13,7 +13,7 @@ import { isNestedShellCommand, tokenizeCommandLine } from './bash-line.js';
13
13
  import { pathCandidates, pathSegments, provenChangePath, resolveDotSegments, someStringValue, untokenizableLineCandidates, } from './mention.js';
14
14
  import { commandBasename, redirectWriteRule, sedInPlaceRule, teeRule } from './mutation-rules.js';
15
15
  import { outcomeFromVerdict, UNJUDGEABLE_OUTCOME } from './run-covenant.js';
16
- import { DEFAULT_READ_ONLY_COMMANDS, matchesReadOnlyEntry } from './shell-mod.js';
16
+ import { DEFAULT_READ_ONLY_COMMANDS, matchesConditionalReadOnlyCommand, matchesReadOnlyEntry, usesDefaultReadOnlyCommands, } from './shell-mod.js';
17
17
  // The rule set is fixed, assembled exactly as shell-mod assembles it: dropping one would be
18
18
  // a detection hole, and the two judges must not diverge on what counts as a write.
19
19
  const MUTATION_RULES = [redirectWriteRule, teeRule, sedInPlaceRule];
@@ -55,6 +55,7 @@ function resolveTranscript(spec) {
55
55
  readOnlyEntries: spec.readOnlyCommands
56
56
  .map((entry) => entry.split(/\s+/).filter((word) => word !== ''))
57
57
  .filter((entry) => entry.length > 0),
58
+ conditionalReadersEnabled: usesDefaultReadOnlyCommands(spec.readOnlyCommands),
58
59
  };
59
60
  }
60
61
  /**
@@ -96,7 +97,7 @@ function argsNameTranscript(value, transcript) {
96
97
  * it has to break before the allowlist gets a chance to absolve it. The letters skip `(c)`
97
98
  * because this ladder has no opaque-mention clause, matching the shell ladder's numbering
98
99
  * 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
+ * tokenizer could not read, which withholds read-only proof.
100
101
  */
101
102
  function judgeCommand(command, transcript, lineFullyRead) {
102
103
  // (a) Precise rules: a detected mutation whose target is the transcript breaks.
@@ -118,15 +119,17 @@ function judgeCommand(command, transcript, lineFullyRead) {
118
119
  if (command.redirects.some((r) => r.operator.includes('>') && r.target.opaque)) {
119
120
  return `opaque redirect target alongside the session transcript ${transcript.path}`;
120
121
  }
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.
122
+ // (e) Read-only proof: the allowlist or a finite argument-sensitive reader absolves the
123
+ // mention in every spelling. A nested shell (`eval`/`sh -c …`) re-parses its string args,
124
+ // so it is never provably a read.
123
125
  // A line carrying an unread span is refused the same way: reading the session is free, but
124
126
  // only on a line we finished reading.
125
127
  const first = command.words[0];
126
128
  const firstBasename = first !== undefined ? commandBasename(first) : '';
127
129
  if (lineFullyRead &&
128
130
  !isNestedShellCommand(firstBasename) &&
129
- transcript.readOnlyEntries.some((entry) => matchesReadOnlyEntry(command, entry))) {
131
+ (transcript.readOnlyEntries.some((entry) => matchesReadOnlyEntry(command, entry)) ||
132
+ (transcript.conditionalReadersEnabled && matchesConditionalReadOnlyCommand(command)))) {
130
133
  return null;
131
134
  }
132
135
  // (f) Backstop — mention + unproven = block.
@@ -56,23 +56,35 @@ Codex에서 개발할 때 씁니다.
56
56
  제공합니다. 먼저 `pdks init`으로 초기 파일을 만든 뒤 Codex 등록 산출물을 씁니다.
57
57
  3. Codex에서 `/hooks`로 생성된 훅을 승인합니다. 승인하기 전까지는 훅을 건너뜁니다.
58
58
 
59
- Codex 프로젝트에는 `.codex/hooks/covenant-pretooluse.mjs` 위임자와 `.codex/hooks.json`의 항목이
60
- 생깁니다. JSON은 덮어쓰지 않고 병합합니다. 다른 이벤트, 다른 matcher, 설치기가 모르는 키는
61
- 그대로 둡니다. 초기 설정은 기본적으로 `.codex/hooks`를 보호합니다.
59
+ Codex 프로젝트에는 `.codex/hooks/covenant-pretooluse.mjs` 위임자와 `.codex/hooks.json`의
60
+ `PreToolUse`, `UserPromptSubmit`, `PostToolUse`, `SessionEnd` 항목이 생깁니다. JSON은
61
+ 덮어쓰지 않고 병합합니다. 사용자 항목, 같은 항목의 다른 handler, 다른 이벤트, 설치기가 모르는
62
+ 키는 그대로 둡니다. 초기 설정은 기본적으로 `.codex/hooks`를 보호합니다.
62
63
 
63
64
  **승인은 선택이 아닙니다.** Codex는 훅 정의의 해시로 신뢰를 기록하므로, 새로 쓴 훅은 검토
64
65
  대상으로 표시되고 누군가 승인하기 전까지 건너뛰어집니다. 그때까지는 아무것도 판정되지
65
66
  않습니다. `init`은 실행할 때마다 바이트가 같은 명령 문자열을 쓰므로, 다시 설치해도 이미 받은
66
67
  승인이 무효가 되지 않습니다.
67
68
 
68
- Codex는 모든 파일 편집을 `apply_patch` 하나로 정규화하고, 경로 인자가 아니라 패치 텍스트를
69
+ Codex는 훅에 도달하는 모든 파일 편집을 `apply_patch` 하나로 정규화하고, 경로 인자가 아니라 패치 텍스트를
69
70
  보냅니다. `Edit`과 `Write`는 훅 파일에 적을 수 있는 matcher 별칭이며 도구 이름으로 도착하지
70
71
  않습니다. 패치 하나가 여러 파일을 건드리면 파일마다 IR 원소 하나가 실리고, 그중 하나라도
71
72
  차단되면 호출 전체가 차단됩니다.
72
73
 
73
- Codex에는 대화 기록 채널이 없어서 세션 증인(witness) 밸브가 읽을 사람 메시지가 없습니다.
74
- 의도한 편집이 차단되면 자신의 터미널에서 수행하세요. 세션 어댑터를 한 프로젝트에 둘 이상
75
- 설치하면 호출마다 판정기가 번 실행될 수 있습니다.
74
+ **승인된 훅도 Code Mode는 덮지 못합니다.** codex-cli 0.154에서 Code Mode `exec` 호출과 그
75
+ JavaScript 안에 중첩된 도구 호출은 `PreToolUse`에 도달하지 않으므로
76
+ ([openai/codex#23411](https://github.com/openai/codex/issues/23411)), 경로로 이루어진 편집은
77
+ `/hooks`에 훅이 Active로 표시되는 동안에도 판정되지도 기록되지도 않습니다. `init`이 이 사실을
78
+ `note:` 줄로 출력하고, [패키지 레퍼런스](../reference/packages/adapter-codex.ko.md#limits)가
79
+ 다른 선언된 한계와 함께 나열합니다.
80
+
81
+ Codex의 대화 기록 형식은 계속 불안정하므로 해석하지 않습니다. 대신 `UserPromptSubmit`이
82
+ 시각을 붙인 사람 메시지를, `PostToolUse`가 완료된 도구 호출을 `.polydeukes/codex-sessions/`
83
+ 아래의 어댑터 소유 파일에 기록하고 `SessionEnd`가 지웁니다. 의도한 차단을 풀려면 설정된 증인
84
+ 토큰을 첫 줄에 단독으로 보낸 뒤 호출을 다시 시도합니다. 복구 메시지가
85
+ `UserPromptSubmit` 증거가 없다고 알리면 재시도가 증인 밸브에 닿지 못하므로 자신의 터미널을
86
+ 사용합니다. 세션 어댑터를 한 프로젝트에 둘 이상 설치하면 호출마다 판정기가 두 번 실행될 수
87
+ 있습니다.
76
88
 
77
89
  <a id="change-set-surface"></a>
78
90
  ## 변경 집합 표면
@@ -58,24 +58,36 @@ Use this when the project is developed in Codex.
58
58
  runs `pdks init` for the scaffold, then writes the Codex registration artifacts.
59
59
  3. Approve the generated hook with `/hooks` in Codex. Until you do, it is skipped.
60
60
 
61
- A Codex tree gets a delegator at `.codex/hooks/covenant-pretooluse.mjs` and an entry in
62
- `.codex/hooks.json`. That JSON is merged, not overwritten: other events, other matchers, and
63
- keys the installer does not know stay where they are. The scaffold config protects
64
- `.codex/hooks` by default.
61
+ A Codex tree gets a delegator at `.codex/hooks/covenant-pretooluse.mjs` and entries for
62
+ `PreToolUse`, `UserPromptSubmit`, `PostToolUse`, and `SessionEnd` in `.codex/hooks.json`.
63
+ That JSON is merged, not overwritten: user entries, sibling handlers, other events, and keys
64
+ the installer does not know stay where they are. The scaffold config protects `.codex/hooks`
65
+ by default.
65
66
 
66
67
  **Approval is not optional.** Codex records trust against the hash of a hook's definition, so a
67
68
  newly written hook is listed for review and skipped until someone approves it — until then
68
69
  nothing is judged. `init` writes a byte-identical command string on every run, so a re-install
69
70
  does not invalidate an approval you already gave.
70
71
 
71
- Codex normalises every file edit into one tool, `apply_patch`, and sends the patch text rather
72
- than a path argument. `Edit` and `Write` are matcher aliases you may write in the hooks file;
73
- they never arrive as the tool name. One patch that touches several files carries one IR element
74
- per file, and any one of them blocking blocks the whole call.
75
-
76
- Codex supplies no transcript channel, so the session witness valve has no human message to read.
77
- For an intentional blocked edit, use your own terminal. Installing more than one session adapter
78
- in one project can run the judge twice per call.
72
+ Codex normalises every file edit that reaches the hook into one tool, `apply_patch`, and sends
73
+ the patch text rather than a path argument. `Edit` and `Write` are matcher aliases you may
74
+ write in the hooks file; they never arrive as the tool name. One patch that touches several
75
+ files carries one IR element per file, and any one of them blocking blocks the whole call.
76
+
77
+ **An approved hook does not cover Code Mode.** In codex-cli 0.154 a Code Mode `exec` dispatch,
78
+ and the tool calls nested in its JavaScript, do not reach `PreToolUse`
79
+ ([openai/codex#23411](https://github.com/openai/codex/issues/23411)), so an edit made that way
80
+ is neither judged nor logged even while `/hooks` shows the hook Active. `init` prints this as a
81
+ `note:` line; the [package reference](../reference/packages/adapter-codex.md#limits)
82
+ lists it with the other declared limits.
83
+
84
+ Codex's transcript format remains unstable and is never parsed. Instead, `UserPromptSubmit`
85
+ records timestamped human messages and `PostToolUse` records completed tool calls in an
86
+ adapter-owned file under `.polydeukes/codex-sessions/`; `SessionEnd` removes it. To release an
87
+ intentional block, send the configured witness token alone on the first line, then retry the
88
+ call. If the recovery message says no `UserPromptSubmit` evidence was recorded, use your own
89
+ terminal because the retry cannot reach the witness valve. Installing more than one session
90
+ adapter in one project can run the judge twice per call.
79
91
 
80
92
 
81
93
  <a id="change-set-surface"></a>