@r1z4x/tezgah 1.7.0 → 1.8.2

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.
@@ -228,6 +228,48 @@ const SECRET_SINK =
228
228
  // message about the rule rather than a credential in a write. `|` is NOT a
229
229
  // boundary here but a carrier, and the split is read off the masked text.
230
230
  const SEGMENT = /\|\||&&|[;&\n]/g
231
+ // Plan 096, as hooks/tezgah_gate reads it (SECRET_PREFIX, SECRET_RUNNER, ...,
232
+ // _secret_runner): a token held only by a program's leading `NAME=value` words
233
+ // is in that program's env, so its redirected output carries no key - but only
234
+ // for a script runner named by itself, given a script file, with no option that
235
+ // runs its own text, stdin or a preload, or traces. Any other program holds.
236
+ const SECRET_PREFIX =
237
+ /^[\s;&|]*(?:env\s+)?(?:[A-Za-z_]\w*=(?:'[^']*'|"(?:\\.|[^"\\])*"|\$\([^()]*\)|`[^`]*`|\\.|[^\s;&|()<>'"`\\])*\s+)+/
238
+ // the sinks that carry a program's output and nothing but it (SECRET_OUTPUT)
239
+ const SECRET_OUTPUT = />>?(?![&=])|\|\s*tee\b/g
240
+ const SECRET_RUNNER = /^(?:python[\d.]*|node|bun|deno|bash|sh|zsh)$/
241
+ const SECRET_SCRIPT = /^[^$`\s]+\.(?:py|js|mjs|ts|sh)$/
242
+ const SECRET_INLINE = {python: /^-[A-Za-z]*[cmi]/,
243
+ sh: /^(?:-[A-Za-z]*[csxv]|-o$|--(?:verbose|debugger))/,
244
+ js: /^(?:-[A-Za-z]*[epri]|--(?:eval|print|require|import|loader|experimental-loader|preload|interactive)(?:=|$))/}
245
+ // the line holds (SECRET_ENVIRON, SECRET_STARTUP, SECRET_TRACE, SECRET_FUNCTION)
246
+ const SECRET_ENVIRON = /\/proc\/[^/\s]*\/environ/
247
+ const SECRET_STARTUP =
248
+ /(?<![\w$])(?:SHELLOPTS|BASHOPTS|BASH_ENV|ENV|PS4|ZDOTDIR|NODE_OPTIONS|PYTHONSTARTUP|PYTHONINSPECT|PERL5OPT|PERL5DB|RUBYOPT|LD_PRELOAD|DYLD_\w*)=/
249
+ const SECRET_TRACE = /(?:^|[\s;&|(])set\s+(?:-[A-Za-z]*[xv]|-o\s+(?:xtrace|verbose))/
250
+ const SECRET_FUNCTION =
251
+ /(?:^|[\s;&|(])(?:function\s+[^\s(){};&|]+|[^\s(){};&|$=]+\s*\(\s*\))/
252
+ const SECRET_UNEXPANDED = /\$[A-Za-z_{(]|`/
253
+ // the programs a carried name is printed by when they own a sink
254
+ const SECRET_PRINTERS = new Set(["echo", "printf", "cat", "eval", "awk", "perl",
255
+ "ruby"])
256
+ // a versioned python is read by /^python[\d.]+$/: only pythonBin() spells the
257
+ // interpreter's name (tests/test_interpreter.py)
258
+ const SECRET_INTERPRETERS = new Set(["python", "sh", "bash", "zsh", "dash"])
259
+ const SECRET_KEYWORDS = new Set(["if", "then", "elif", "else", "do", "while",
260
+ "until", "!", "{", "time"])
261
+ // the wrappers `_program` reads past, with the options that take a value
262
+ const SECRET_WRAPPERS = new Map([
263
+ ["sudo", ["-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U"]],
264
+ ["env", ["-u", "--unset", "-C", "--chdir"]], ["nice", ["-n", "--adjustment"]],
265
+ ["nohup", []], ["exec", ["-a"]], ["time", ["-f", "-o"]], ["command", []],
266
+ ["timeout", ["-s", "--signal", "-k", "--kill-after"]], ["stdbuf", ["-i", "-o", "-e"]]])
267
+ const SECRET_INTERPRETER_OPTS = ["-X", "-W", "-o", "+o", "-O"]
268
+ // `NAME=value` at a word start, and the `$NAME`/`${NAME}` a later word reads
269
+ // (hooks/tezgah_gate.SHELL_ASSIGN, SHELL_VAR)
270
+ const SECRET_ASSIGN =
271
+ /(?:^|(?<=[\s;&|(]))([A-Za-z_][A-Za-z0-9_]*)=((?:'[^']*'|"[^"]*"|[^\s;&|)'"])*)/g
272
+ const SECRET_VAR = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g
231
273
  const SECRET_DENY =
232
274
  "Credential write denied: this command would land a credential in a file " +
233
275
  "(`>`/`>>`, `tee`, `git add` or a curl trace). Record the credential's " +
@@ -1941,23 +1983,148 @@ function realPath(p) {
1941
1983
  // The refusal when this command would write a credential into a file, or null.
1942
1984
  // Reading an env var or running a tool with a key in its env is the normal work
1943
1985
  // this must not touch, so a token only counts next to a write sink, and a sink
1944
- // only carries the text of its own simple command.
1986
+ // only carries the text of its own simple command. A token held only by the
1987
+ // leading `NAME=value` words of an allowlisted program is in that program's env
1988
+ // (envPrefixOnly), unless the line reads /proc's environ or defines a function.
1989
+ // A name an earlier command assigned a token is carried, and a printer owning a
1990
+ // sink that reads it still writes it (printerOwnsSink; plan 096).
1945
1991
  function secretCommand(command) {
1946
1992
  const c = String(command || "")
1947
1993
  if (!c || !SECRET_TOKEN.test(c)) return null
1948
1994
  const masked = maskText(c)
1995
+ const relax = !(SECRET_ENVIRON.test(c) || SECRET_STARTUP.test(c)
1996
+ || SECRET_FUNCTION.test(masked) || SECRET_TRACE.test(masked))
1949
1997
  let start = 0
1998
+ const carried = new Set()
1950
1999
  const ends = [...masked.matchAll(SEGMENT)].map((m) => m.index)
1951
2000
  for (const end of ends.concat(c.length)) {
1952
- if (SECRET_TOKEN.test(c.slice(start, end))
1953
- && SECRET_SINK.test(masked.slice(start, end))) {
1954
- return SECRET_DENY
2001
+ const text = c.slice(start, end)
2002
+ if (SECRET_SINK.test(masked.slice(start, end))) {
2003
+ if (SECRET_TOKEN.test(text) && !(relax && envPrefixOnly(text))) return SECRET_DENY
2004
+ if ([...text.matchAll(SECRET_VAR)].some((v) => carried.has(v[1] || v[2]))
2005
+ && printerOwnsSink(text, masked.slice(start, end))) {
2006
+ return SECRET_DENY
2007
+ }
2008
+ }
2009
+ for (const a of text.matchAll(SECRET_ASSIGN)) {
2010
+ if (SECRET_TOKEN.test(a[0])) carried.add(a[1])
1955
2011
  }
1956
2012
  start = end
1957
2013
  }
1958
2014
  return null
1959
2015
  }
1960
2016
 
2017
+ // True when every token in this simple command's text sits in its leading
2018
+ // `NAME=value` words, no sink runs inside them, the sinks after them carry only
2019
+ // the program's output (a `git add` or a curl trace holds), and the program is
2020
+ // on the allowlist (hooks/tezgah_gate._env_prefix_only).
2021
+ function envPrefixOnly(text) {
2022
+ const m = text.match(SECRET_PREFIX)
2023
+ if (!m) return false
2024
+ const rest = text.slice(m[0].length)
2025
+ return !(SECRET_TOKEN.test(rest) || SECRET_SINK.test(m[0])
2026
+ || SECRET_SINK.test(rest.replace(SECRET_OUTPUT, " "))
2027
+ || !secretRunner(rest))
2028
+ }
2029
+
2030
+ // the words of the first command `text` runs, a `$( )` body skipped
2031
+ function firstCommand(text) {
2032
+ const first = shellCommands(text.replace(/^[ \t\n;&|]+/, ""))
2033
+ .find((cmd) => cmd[1] !== "$)")
2034
+ return first ? first[0] : []
2035
+ }
2036
+
2037
+ // True when the first program `text` runs, past the wrappers, is a script
2038
+ // runner named by itself (a path could be any program) given a script file,
2039
+ // with no `-` or inline, stdin, preload, interactive or trace option before it
2040
+ // (hooks/tezgah_gate._secret_runner).
2041
+ function secretRunner(text) {
2042
+ const words = secretPastWrappers(firstCommand(text))
2043
+ if (!words.length || !SECRET_RUNNER.test(words[0])) return false
2044
+ const name = words[0]
2045
+ let args = words.slice(1)
2046
+ if (name === "deno") {
2047
+ if (args[0] !== "run") return false
2048
+ args = args.slice(1)
2049
+ }
2050
+ const inline = SECRET_INLINE[name.startsWith("python") ? "python"
2051
+ : ["node", "bun", "deno"].includes(name) ? "js" : "sh"]
2052
+ for (let i = 0; i < args.length;) {
2053
+ const arg = args[i]
2054
+ if (arg === "-" || inline.test(arg)) return false
2055
+ if (!/^[-+]/.test(arg)) return SECRET_SCRIPT.test(arg)
2056
+ i += SECRET_INTERPRETER_OPTS.includes(arg) ? 2 : 1
2057
+ }
2058
+ return false
2059
+ }
2060
+
2061
+ // True when a pipeline stage that owns a file sink - a `>` to a file other than
2062
+ // /dev/null, or a pipe into `tee` - is a printer
2063
+ // (hooks/tezgah_gate._printer_owns_sink).
2064
+ function printerOwnsSink(text, masked) {
2065
+ const cuts = [...masked.matchAll(/(?<!>)\|/g)].map((m) => m.index)
2066
+ const starts = [0, ...cuts.map((k) => k + 1)]
2067
+ const stops = [...cuts, text.length]
2068
+ for (let k = 0; k < starts.length; k++) {
2069
+ const a = starts[k], b = stops[k]
2070
+ let owns = [...masked.slice(a, b).matchAll(/>>?(?![&=])/g)].some((m) =>
2071
+ text.slice(a + m.index + m[0].length, b).trim().split(/\s+/)[0] !== "/dev/null")
2072
+ if (k + 1 < starts.length && /^\s*tee\b/.test(masked.slice(starts[k + 1]))) owns = true
2073
+ if (owns && secretPrinter(text.slice(a, b))) return true
2074
+ }
2075
+ return false
2076
+ }
2077
+
2078
+ // True when the first program `text` runs prints a carried name
2079
+ // (hooks/tezgah_gate._secret_printer).
2080
+ function secretPrinter(text) {
2081
+ const program = secretProgram(firstCommand(text))[0]
2082
+ return SECRET_PRINTERS.has(program) || SECRET_INTERPRETERS.has(program)
2083
+ || /^python[\d.]+$/.test(program) || SECRET_UNEXPANDED.test(program)
2084
+ }
2085
+
2086
+ // `words` past keywords, env assignments and the wrappers
2087
+ // (hooks/tezgah_gate._past_wrappers)
2088
+ function secretPastWrappers(words) {
2089
+ let i = 0
2090
+ while (i < words.length) {
2091
+ const word = words[i]
2092
+ if (/^[A-Za-z_]\w*=/.test(word) || SECRET_KEYWORDS.has(word)) { i += 1; continue }
2093
+ const wrapper = basename(word)
2094
+ const opts = SECRET_WRAPPERS.get(wrapper)
2095
+ if (!opts) break
2096
+ i += 1
2097
+ while (i < words.length && words[i].startsWith("-")) i += opts.includes(words[i]) ? 2 : 1
2098
+ if (wrapper === "timeout" && i < words.length) i += 1
2099
+ }
2100
+ return words.slice(i)
2101
+ }
2102
+
2103
+ // [program basename without `.py`, its arguments] for one simple command, past
2104
+ // the wrappers and an interpreter's options before its script
2105
+ // (hooks/tezgah_gate._program).
2106
+ function secretProgram(words) {
2107
+ words = secretPastWrappers(words)
2108
+ if (words[0] === "[") {
2109
+ const close = words.indexOf("]")
2110
+ words = words.slice(close < 0 ? words.length : close + 1)
2111
+ }
2112
+ if (!words.length) return ["", []]
2113
+ let name = basename(words[0])
2114
+ if (SECRET_INTERPRETERS.has(name) || /^python[\d.]+$/.test(name)) {
2115
+ const own = name.startsWith("python") ? /^-[A-Za-z]*[cm]$/ : /^-[A-Za-z]*c[A-Za-z]*$/
2116
+ let j = 1
2117
+ while (j < words.length && /^[-+]/.test(words[j]) && words[j] !== "-") {
2118
+ if (own.test(words[j])) { j = words.length + 1; break }
2119
+ j += SECRET_INTERPRETER_OPTS.includes(words[j]) ? 2 : 1
2120
+ }
2121
+ if (j < words.length) words = words.slice(j)
2122
+ }
2123
+ if (!words.length) return ["", []]
2124
+ name = basename(words[0])
2125
+ return [name.endsWith(".py") ? name.slice(0, -3) : name, words.slice(1)]
2126
+ }
2127
+
1961
2128
  // --- untrusted content: the channel and the label ---------------------------
1962
2129
  // The two halves the Python hosts get from
1963
2130
  // hooks/tezgah_integrity.untrusted_source and hooks/tezgah_untrusted.marks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r1z4x/tezgah",
3
- "version": "1.7.0",
3
+ "version": "1.8.2",
4
4
  "description": "One shared working contract for every AI coding assistant you run: Claude Code, Codex, Cursor, opencode, dsh and oh-my-pi (omp).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -72,9 +72,15 @@ Input: `$ARGUMENTS` (free text describing the work). Run these steps in order.
72
72
  9. Start it yourself with `tezgah-task` - the lifecycle is the session's, never
73
73
  a command handed to the user:
74
74
  `~/.config/tezgah/bin/tezgah-task start NNN --phase discovery` writes `phase:`
75
- into this file and clears the phase from every other open plan, and the gate
76
- then allows a write only from `implementation` on and only inside the plan's
77
- `allowed_paths:` (`--allow '<glob>' ...` replaces them). Entering
75
+ into this file and clears the phase from every other open plan. To run two
76
+ plans at once, give each its own worktree on its own branch (`branch:`, or
77
+ `plan/NNN-slug`) before `start`: a plan held by its worktree keeps its phase
78
+ when the plan being started is held by one too and their `allowed_paths:`
79
+ share no prefix, and the gate holds a write in each worktree to that plan's
80
+ record. The gate then allows a write only from `implementation` on and only
81
+ inside the plan's `allowed_paths:` (`--allow '<glob>' ...` replaces them);
82
+ `phase`, `allow` and `stop` run inside a plan's worktree act on that plan,
83
+ and `allow` refuses a scope another running plan's worktree holds. Entering
78
84
  `implementation` or `verification` with no allowlist is refused unless
79
85
  `--any-path` is passed, so keep the plan's scope and never drop it to get
80
86
  through. Three more refusals land at the same moment, and you fix them in the
@@ -90,9 +96,14 @@ Input: `$ARGUMENTS` (free text describing the work). Run these steps in order.
90
96
  the scope with `tezgah-task allow <glob> ... --why "<reason>"`, which records the reason. Never retype the record:
91
97
  the gate refuses a write to it, because the CLI's checks would be skipped.
92
98
  Tick an Acceptance box with `tezgah-task tick NNN <n>` (`--undo` unticks),
93
- and rewrite State or Next with `tezgah-task note NNN state|next "<text>"`.
99
+ rewrite State or Next with `tezgah-task note NNN state|next "<text>"`, and
100
+ record the spike's answer with `tezgah-task spike NNN "<answer>"`, which
101
+ writes `spike_recorded:` on the plan, active or not. These three work from
102
+ the main checkout or from any of its worktrees.
94
103
  After the merge, `tezgah-task close NNN done` deactivates the plan,
95
- moves it to done and removes every merged worktree.
104
+ moves it to done and removes every merged worktree. It refuses while an
105
+ Acceptance box is open: tick it, or, only when the owner accepted the item
106
+ as not met, end it with `UNMET: <reason, who accepted it>`.
96
107
 
97
108
  10. If the work settles a decision that is hard to reverse, spans components, or
98
109
  keeps being re-explained, record it as an ADR under `.tezgah/decisions/` with
@@ -133,14 +144,16 @@ after:
133
144
 
134
145
  `allowed_paths:` is written here, at creation (step 5): the scope the task's
135
146
  writes have to stay inside, one glob per `- ` line. `tezgah-task` adds the rest:
136
- `phase:` - `discovery`, `implementation` or `verification`, at most one open plan
137
- carrying it - is the activation key the gate reads, set when the session starts
147
+ `phase:` - `discovery`, `implementation` or `verification`, on one open plan, or
148
+ on one plan per worktree when their scopes share no prefix (step 9) - is the
149
+ activation key the gate reads, set when the session starts
138
150
  the task itself (step 9); `allowed_from:` records where a scope the CLI replaced came from.
139
151
  `tezgah-task phase verification` lists what the plan owes before it is done: its
140
152
  acceptance commands run unpiped, and a fresh reviewer (the host's
141
153
  `tezgah-reviewer` subagent) reading the diff, whose verdict
142
154
  `tezgah-task review NNN <reviewer> approve|changes` records as `review:`.
143
- `tezgah-task close NNN done` (plan-sync) refuses without an `approve`.
155
+ `tezgah-task close NNN done` (plan-sync) refuses without an `approve`, and on an
156
+ unticked Acceptance item with no `UNMET: <reason>`; `discarded` needs neither.
144
157
 
145
158
  `after:` names the plans that must be done before this one starts - a `- NNN`
146
159
  block or `after: 012, 015`. `tezgah-task status` reads it: each open plan's
@@ -160,7 +173,8 @@ return to is what a large refactor has instead of 200 per-file snapshots
160
173
  The four `spike:` keys are optional and go together, on a plan that must answer
161
174
  one question before it can be built: `spike:` is that question, `spike_box:` the
162
175
  time box (minutes, or one focused session), `spike_recorded:` where the answer
163
- is recorded - empty until it is, which is what refuses `implementation` - and
176
+ is recorded (`tezgah-task spike NNN "<answer>"` writes it) - empty until it is,
177
+ which is what refuses `implementation` - and
164
178
  `spike_throwaway:` whether the code is thrown away once it has answered. Nothing
165
179
  enforces the throwaway half, because `allowed_paths:` already does: a spike pins
166
180
  its scope to a scratch path and is never merged, and the knowledge, not the
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env python3
2
- """Rewrite the status table in <git root>/.tezgah/plans/README.md from .tezgah/plans/open/*.md.
2
+ """Rewrite the status table in <main checkout>/.tezgah/plans/README.md from .tezgah/plans/open/*.md.
3
3
  Usage: python3 render_table.py [--pr-info 'NNN=text' ...] (extra text appended to the pr cell)
4
4
  python3 render_table.py --acceptance (the acceptance items that name no
5
5
  command, with the counts it read; a report, not a gate - always exit 0)
6
6
  python3 render_table.py --acceptance --strict (the same report, and exit 1
7
7
  when an item still under .tezgah/plans/open names no command either)
8
8
  Creates .tezgah/plans/README.md, or appends the marker block to it, when either is missing."""
9
- import glob, os, re, subprocess, sys
9
+ import glob, os, re, sys
10
10
  # The one reader of a plan file lives in the module that owns the record, so the
11
11
  # table and the acceptance report cannot disagree about what a plan says. Three
12
12
  # dirnames up from skills/plan-add/render_table.py is the tree that holds hooks/,
@@ -14,7 +14,12 @@ import glob, os, re, subprocess, sys
14
14
  HERE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
15
15
  sys.path.insert(0, os.path.join(HERE, "hooks"))
16
16
  import tezgah_task as tt # noqa: E402
17
- root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()
17
+ # The project's main checkout, the one that keeps `.tezgah/plans`: git's own
18
+ # top-level answered `.tezgah` from inside it (its private repository) and a
19
+ # worktree from a linked checkout, and the table was written there instead.
20
+ root = tt.main_repo(os.getcwd())
21
+ if not root:
22
+ sys.exit("render_table: %s is not inside a git checkout" % os.getcwd())
18
23
 
19
24
 
20
25
  def acceptance(strict):
@@ -258,9 +258,9 @@ Routing: `~/.config/tezgah/bin/tezgah-route "<brief>"` names the worker - tezgah
258
258
  -frontier, or the specialist whose whole job the brief is (tezgah-tester,
259
259
  -docs, -security, -ui, -researcher, -reviewer) - and the spawn uses that name,
260
260
  never the host's generic task agent (overrides for stored data, credentials,
261
- security first; then a Jev judgement; the static phase table with no key). A worker that answers
262
- `ESCALATE:` is restarted on tezgah-frontier with the original brief, never
263
- continued; the main thread never switches model (the cache is per model). A
261
+ security first; then a Jev judgement; the static phase table with no key). A report missing an output field, or an
262
+ `ESCALATE:`, gets one narrower re-brief of the same role naming that field or judgement; only a second miss is
263
+ restarted on tezgah-frontier with the original brief, never continued; the main thread never switches model (the cache is per model). A
264
264
  code-discovery brief names codegraph's tools (`codegraph callers`, `callees`,
265
265
  `impact`, `node`, `files`, `codegraph_explore`) and says "answer from
266
266
  the graph, grep only for literal text"; never a grep-only explorer (observed).