planner: the admission gate authorises a kind, not its arguments #10

Description

@Shashankss1205

Summary

Two related gaps in the admission gate, both documented and both deliberate:

Arguments are not authorised. No rule reaches ProposedNode.args, so a proposal carrying args={"path": "/etc/passwd"} is admitted on the strength of its kind alone. Materializer drops args by default (forward_args=False); turning that on hands a model's unchecked dictionary to your factory, and gating it becomes the factory's job.

parent_depth is the caller's word. The checker cannot see how deep the run actually is, so a caller that always passes 0 has no recursion limit beyond the nesting visible inside a single proposal.

Why this matters

The admission gate is the part of this project with no prior art to copy, and it is the reason the rest exists: a planner proposes, a deterministic checker admits, and only then does anything execute. Its five checks are strong — kind in the registry, edge permitted between kinds, worst case within the remaining budget, depth within limit, acyclic — and a decision keyed on kind rather than instance name means renaming cannot launder a denied capability. Closing the argument gap is what would let graph engineering claim that an admitted proposal is safe to run rather than merely well-shaped, which is the difference between a gate and a shape check.

Where in the code

  • grapharc/planner/admission.pyAdmissionChecker.check and the five checks; :775_find_cycle; :271 the tier-ordering note
  • grapharc/planner/proposal.pyProposedNode, Subgraph, _NAME (note extra="forbid", so a proposal cannot carry code)
  • grapharc/planner/materialize.pyMaterializer, forward_args, and the fingerprint match that binds materialisation to the authorisation
  • grapharc/planner/loop.py — where parent_depth is passed
  • grapharc/policy/engine.pycheck_node, if argument rules belong in the document

What to change

Two separable pieces; either is a valid PR.

Argument authorisation. The design question is where the schema comes from.

  • A NodeSpec could declare an args schema (a Pydantic model) that the checker validates a proposal's args against. This keeps the registry as the source of truth, which matches the existing rule that costs come from the registry, never the proposal.
  • Or argument rules live in the policy TOML, next to the node and edge rules.
  • Whichever you choose: a rejection must remain data. AdmissionResult.feedback() hands the per-check list with codes and remedies back to the planner as its next round's input, and the loop never retries an identical proposal. A new check must produce a reason code and a remedy in that same shape, and must appear in the admission trace event's failed-check list.
  • Nothing may run during a check: NodeSpec.factory is never called and the budget meter is read, not written. Validation must not violate that.
  • Decide what happens to forward_args=False. If args are now authorised, is forwarding them still off by default?

Real depth. Give the checker a trustworthy depth rather than a caller-supplied integer — most likely by threading it through RunContext or the loop's own state, so a caller cannot understate it. Then decide whether an understated depth is a rejection or an error.

How to verify

uv run pytest tests/test_admission.py tests/test_planner_loop.py -q
uv run pytest -q
uv run ruff check .

Follow the adversarial style already in tests/test_admission.py: renaming a denied kind does not evade the policy, nor does hiding the rename in a nested scope, and every failed check is reported rather than just the first. New tests should include a traversing path in args being refused, and a caller that lies about parent_depth gaining nothing.

Acceptance criteria

  • A proposal whose arguments violate a declared schema or rule is refused, with a reason code and remedy in feedback()
  • Nothing executes during a check — no factory call, no meter write
  • Every failed check is still reported, not just the first
  • The refusal appears on the admission trace event and does not inflate node-execution counts
  • Materialisation still binds to the authorisation by fingerprint
  • Depth cannot be understated by the caller (if you take that half)
  • The README paragraphs stating these two limits are updated
  • uv run pytest green, uv run ruff check . clean

Skill level — experience required

This is the security core of the project, and the existing tests are adversarial on purpose — they assume someone is trying to get a denied capability past the gate. You need to understand why every decision keys on kind and never on name, why a proposal cannot carry code, and why a rejection is data rather than a downgraded approval, before you change what is checked. Please propose your design in a comment first; a check that can be bypassed is worse than a documented gap, because the gap is at least honest.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureChanges a subsystem boundary or a cross-cutting contractenhancementNew feature or requestexperience requiredDeep familiarity with the codebase or domain needed; not a starter taskhelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      planner: the admission gate authorises a kind, not its arguments #10

      Description

      @Shashankss1205

      Summary

      Two related gaps in the admission gate, both documented and both deliberate:

      Arguments are not authorised. No rule reaches ProposedNode.args, so a proposal carrying args={"path": "/etc/passwd"} is admitted on the strength of its kind alone. Materializer drops args by default (forward_args=False); turning that on hands a model's unchecked dictionary to your factory, and gating it becomes the factory's job.

      parent_depth is the caller's word. The checker cannot see how deep the run actually is, so a caller that always passes 0 has no recursion limit beyond the nesting visible inside a single proposal.

      Why this matters

      The admission gate is the part of this project with no prior art to copy, and it is the reason the rest exists: a planner proposes, a deterministic checker admits, and only then does anything execute. Its five checks are strong — kind in the registry, edge permitted between kinds, worst case within the remaining budget, depth within limit, acyclic — and a decision keyed on kind rather than instance name means renaming cannot launder a denied capability. Closing the argument gap is what would let graph engineering claim that an admitted proposal is safe to run rather than merely well-shaped, which is the difference between a gate and a shape check.

      Where in the code

      • grapharc/planner/admission.pyAdmissionChecker.check and the five checks; :775_find_cycle; :271 the tier-ordering note
      • grapharc/planner/proposal.pyProposedNode, Subgraph, _NAME (note extra="forbid", so a proposal cannot carry code)
      • grapharc/planner/materialize.pyMaterializer, forward_args, and the fingerprint match that binds materialisation to the authorisation
      • grapharc/planner/loop.py — where parent_depth is passed
      • grapharc/policy/engine.pycheck_node, if argument rules belong in the document

      What to change

      Two separable pieces; either is a valid PR.

      Argument authorisation. The design question is where the schema comes from.

      • A NodeSpec could declare an args schema (a Pydantic model) that the checker validates a proposal's args against. This keeps the registry as the source of truth, which matches the existing rule that costs come from the registry, never the proposal.
      • Or argument rules live in the policy TOML, next to the node and edge rules.
      • Whichever you choose: a rejection must remain data. AdmissionResult.feedback() hands the per-check list with codes and remedies back to the planner as its next round's input, and the loop never retries an identical proposal. A new check must produce a reason code and a remedy in that same shape, and must appear in the admission trace event's failed-check list.
      • Nothing may run during a check: NodeSpec.factory is never called and the budget meter is read, not written. Validation must not violate that.
      • Decide what happens to forward_args=False. If args are now authorised, is forwarding them still off by default?

      Real depth. Give the checker a trustworthy depth rather than a caller-supplied integer — most likely by threading it through RunContext or the loop's own state, so a caller cannot understate it. Then decide whether an understated depth is a rejection or an error.

      How to verify

      uv run pytest tests/test_admission.py tests/test_planner_loop.py -q
      uv run pytest -q
      uv run ruff check .

      Follow the adversarial style already in tests/test_admission.py: renaming a denied kind does not evade the policy, nor does hiding the rename in a nested scope, and every failed check is reported rather than just the first. New tests should include a traversing path in args being refused, and a caller that lies about parent_depth gaining nothing.

      Acceptance criteria

      • A proposal whose arguments violate a declared schema or rule is refused, with a reason code and remedy in feedback()
      • Nothing executes during a check — no factory call, no meter write
      • Every failed check is still reported, not just the first
      • The refusal appears on the admission trace event and does not inflate node-execution counts
      • Materialisation still binds to the authorisation by fingerprint
      • Depth cannot be understated by the caller (if you take that half)
      • The README paragraphs stating these two limits are updated
      • uv run pytest green, uv run ruff check . clean

      Skill level — experience required

      This is the security core of the project, and the existing tests are adversarial on purpose — they assume someone is trying to get a denied capability past the gate. You need to understand why every decision keys on kind and never on name, why a proposal cannot carry code, and why a rejection is data rather than a downgraded approval, before you change what is checked. Please propose your design in a comment first; a check that can be bypassed is worse than a documented gap, because the gap is at least honest.

      Activity

      Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        architectureChanges a subsystem boundary or a cross-cutting contractenhancementNew feature or requestexperience requiredDeep familiarity with the codebase or domain needed; not a starter taskhelp wantedExtra attention is needed

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          planner: the admission gate authorises a kind, not its arguments #10

          Description

          @Shashankss1205

          Summary

          Two related gaps in the admission gate, both documented and both deliberate:

          Arguments are not authorised. No rule reaches ProposedNode.args, so a proposal carrying args={"path": "/etc/passwd"} is admitted on the strength of its kind alone. Materializer drops args by default (forward_args=False); turning that on hands a model's unchecked dictionary to your factory, and gating it becomes the factory's job.

          parent_depth is the caller's word. The checker cannot see how deep the run actually is, so a caller that always passes 0 has no recursion limit beyond the nesting visible inside a single proposal.

          Why this matters

          The admission gate is the part of this project with no prior art to copy, and it is the reason the rest exists: a planner proposes, a deterministic checker admits, and only then does anything execute. Its five checks are strong — kind in the registry, edge permitted between kinds, worst case within the remaining budget, depth within limit, acyclic — and a decision keyed on kind rather than instance name means renaming cannot launder a denied capability. Closing the argument gap is what would let graph engineering claim that an admitted proposal is safe to run rather than merely well-shaped, which is the difference between a gate and a shape check.

          Where in the code

          • grapharc/planner/admission.pyAdmissionChecker.check and the five checks; :775_find_cycle; :271 the tier-ordering note
          • grapharc/planner/proposal.pyProposedNode, Subgraph, _NAME (note extra="forbid", so a proposal cannot carry code)
          • grapharc/planner/materialize.pyMaterializer, forward_args, and the fingerprint match that binds materialisation to the authorisation
          • grapharc/planner/loop.py — where parent_depth is passed
          • grapharc/policy/engine.pycheck_node, if argument rules belong in the document

          What to change

          Two separable pieces; either is a valid PR.

          Argument authorisation. The design question is where the schema comes from.

          • A NodeSpec could declare an args schema (a Pydantic model) that the checker validates a proposal's args against. This keeps the registry as the source of truth, which matches the existing rule that costs come from the registry, never the proposal.
          • Or argument rules live in the policy TOML, next to the node and edge rules.
          • Whichever you choose: a rejection must remain data. AdmissionResult.feedback() hands the per-check list with codes and remedies back to the planner as its next round's input, and the loop never retries an identical proposal. A new check must produce a reason code and a remedy in that same shape, and must appear in the admission trace event's failed-check list.
          • Nothing may run during a check: NodeSpec.factory is never called and the budget meter is read, not written. Validation must not violate that.
          • Decide what happens to forward_args=False. If args are now authorised, is forwarding them still off by default?

          Real depth. Give the checker a trustworthy depth rather than a caller-supplied integer — most likely by threading it through RunContext or the loop's own state, so a caller cannot understate it. Then decide whether an understated depth is a rejection or an error.

          How to verify

          uv run pytest tests/test_admission.py tests/test_planner_loop.py -q
          uv run pytest -q
          uv run ruff check .

          Follow the adversarial style already in tests/test_admission.py: renaming a denied kind does not evade the policy, nor does hiding the rename in a nested scope, and every failed check is reported rather than just the first. New tests should include a traversing path in args being refused, and a caller that lies about parent_depth gaining nothing.

          Acceptance criteria

          • A proposal whose arguments violate a declared schema or rule is refused, with a reason code and remedy in feedback()
          • Nothing executes during a check — no factory call, no meter write
          • Every failed check is still reported, not just the first
          • The refusal appears on the admission trace event and does not inflate node-execution counts
          • Materialisation still binds to the authorisation by fingerprint
          • Depth cannot be understated by the caller (if you take that half)
          • The README paragraphs stating these two limits are updated
          • uv run pytest green, uv run ruff check . clean

          Skill level — experience required

          This is the security core of the project, and the existing tests are adversarial on purpose — they assume someone is trying to get a denied capability past the gate. You need to understand why every decision keys on kind and never on name, why a proposal cannot carry code, and why a rejection is data rather than a downgraded approval, before you change what is checked. Please propose your design in a comment first; a check that can be bypassed is worse than a documented gap, because the gap is at least honest.

          Activity

          Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            architectureChanges a subsystem boundary or a cross-cutting contractenhancementNew feature or requestexperience requiredDeep familiarity with the codebase or domain needed; not a starter taskhelp wantedExtra attention is needed

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              planner: the admission gate authorises a kind, not its arguments #10

              Description

              @Shashankss1205

              Summary

              Two related gaps in the admission gate, both documented and both deliberate:

              Arguments are not authorised. No rule reaches ProposedNode.args, so a proposal carrying args={"path": "/etc/passwd"} is admitted on the strength of its kind alone. Materializer drops args by default (forward_args=False); turning that on hands a model's unchecked dictionary to your factory, and gating it becomes the factory's job.

              parent_depth is the caller's word. The checker cannot see how deep the run actually is, so a caller that always passes 0 has no recursion limit beyond the nesting visible inside a single proposal.

              Why this matters

              The admission gate is the part of this project with no prior art to copy, and it is the reason the rest exists: a planner proposes, a deterministic checker admits, and only then does anything execute. Its five checks are strong — kind in the registry, edge permitted between kinds, worst case within the remaining budget, depth within limit, acyclic — and a decision keyed on kind rather than instance name means renaming cannot launder a denied capability. Closing the argument gap is what would let graph engineering claim that an admitted proposal is safe to run rather than merely well-shaped, which is the difference between a gate and a shape check.

              Where in the code

              • grapharc/planner/admission.pyAdmissionChecker.check and the five checks; :775_find_cycle; :271 the tier-ordering note
              • grapharc/planner/proposal.pyProposedNode, Subgraph, _NAME (note extra="forbid", so a proposal cannot carry code)
              • grapharc/planner/materialize.pyMaterializer, forward_args, and the fingerprint match that binds materialisation to the authorisation
              • grapharc/planner/loop.py — where parent_depth is passed
              • grapharc/policy/engine.pycheck_node, if argument rules belong in the document

              What to change

              Two separable pieces; either is a valid PR.

              Argument authorisation. The design question is where the schema comes from.

              • A NodeSpec could declare an args schema (a Pydantic model) that the checker validates a proposal's args against. This keeps the registry as the source of truth, which matches the existing rule that costs come from the registry, never the proposal.
              • Or argument rules live in the policy TOML, next to the node and edge rules.
              • Whichever you choose: a rejection must remain data. AdmissionResult.feedback() hands the per-check list with codes and remedies back to the planner as its next round's input, and the loop never retries an identical proposal. A new check must produce a reason code and a remedy in that same shape, and must appear in the admission trace event's failed-check list.
              • Nothing may run during a check: NodeSpec.factory is never called and the budget meter is read, not written. Validation must not violate that.
              • Decide what happens to forward_args=False. If args are now authorised, is forwarding them still off by default?

              Real depth. Give the checker a trustworthy depth rather than a caller-supplied integer — most likely by threading it through RunContext or the loop's own state, so a caller cannot understate it. Then decide whether an understated depth is a rejection or an error.

              How to verify

              uv run pytest tests/test_admission.py tests/test_planner_loop.py -q
              uv run pytest -q
              uv run ruff check .

              Follow the adversarial style already in tests/test_admission.py: renaming a denied kind does not evade the policy, nor does hiding the rename in a nested scope, and every failed check is reported rather than just the first. New tests should include a traversing path in args being refused, and a caller that lies about parent_depth gaining nothing.

              Acceptance criteria

              • A proposal whose arguments violate a declared schema or rule is refused, with a reason code and remedy in feedback()
              • Nothing executes during a check — no factory call, no meter write
              • Every failed check is still reported, not just the first
              • The refusal appears on the admission trace event and does not inflate node-execution counts
              • Materialisation still binds to the authorisation by fingerprint
              • Depth cannot be understated by the caller (if you take that half)
              • The README paragraphs stating these two limits are updated
              • uv run pytest green, uv run ruff check . clean

              Skill level — experience required

              This is the security core of the project, and the existing tests are adversarial on purpose — they assume someone is trying to get a denied capability past the gate. You need to understand why every decision keys on kind and never on name, why a proposal cannot carry code, and why a rejection is data rather than a downgraded approval, before you change what is checked. Please propose your design in a comment first; a check that can be bypassed is worse than a documented gap, because the gap is at least honest.

              Activity

              Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                architectureChanges a subsystem boundary or a cross-cutting contractenhancementNew feature or requestexperience requiredDeep familiarity with the codebase or domain needed; not a starter taskhelp wantedExtra attention is needed

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  planner: the admission gate authorises a kind, not its arguments #10

                  Description

                  @Shashankss1205

                  Summary

                  Two related gaps in the admission gate, both documented and both deliberate:

                  Arguments are not authorised. No rule reaches ProposedNode.args, so a proposal carrying args={"path": "/etc/passwd"} is admitted on the strength of its kind alone. Materializer drops args by default (forward_args=False); turning that on hands a model's unchecked dictionary to your factory, and gating it becomes the factory's job.

                  parent_depth is the caller's word. The checker cannot see how deep the run actually is, so a caller that always passes 0 has no recursion limit beyond the nesting visible inside a single proposal.

                  Why this matters

                  The admission gate is the part of this project with no prior art to copy, and it is the reason the rest exists: a planner proposes, a deterministic checker admits, and only then does anything execute. Its five checks are strong — kind in the registry, edge permitted between kinds, worst case within the remaining budget, depth within limit, acyclic — and a decision keyed on kind rather than instance name means renaming cannot launder a denied capability. Closing the argument gap is what would let graph engineering claim that an admitted proposal is safe to run rather than merely well-shaped, which is the difference between a gate and a shape check.

                  Where in the code

                  • grapharc/planner/admission.pyAdmissionChecker.check and the five checks; :775_find_cycle; :271 the tier-ordering note
                  • grapharc/planner/proposal.pyProposedNode, Subgraph, _NAME (note extra="forbid", so a proposal cannot carry code)
                  • grapharc/planner/materialize.pyMaterializer, forward_args, and the fingerprint match that binds materialisation to the authorisation
                  • grapharc/planner/loop.py — where parent_depth is passed
                  • grapharc/policy/engine.pycheck_node, if argument rules belong in the document

                  What to change

                  Two separable pieces; either is a valid PR.

                  Argument authorisation. The design question is where the schema comes from.

                  • A NodeSpec could declare an args schema (a Pydantic model) that the checker validates a proposal's args against. This keeps the registry as the source of truth, which matches the existing rule that costs come from the registry, never the proposal.
                  • Or argument rules live in the policy TOML, next to the node and edge rules.
                  • Whichever you choose: a rejection must remain data. AdmissionResult.feedback() hands the per-check list with codes and remedies back to the planner as its next round's input, and the loop never retries an identical proposal. A new check must produce a reason code and a remedy in that same shape, and must appear in the admission trace event's failed-check list.
                  • Nothing may run during a check: NodeSpec.factory is never called and the budget meter is read, not written. Validation must not violate that.
                  • Decide what happens to forward_args=False. If args are now authorised, is forwarding them still off by default?

                  Real depth. Give the checker a trustworthy depth rather than a caller-supplied integer — most likely by threading it through RunContext or the loop's own state, so a caller cannot understate it. Then decide whether an understated depth is a rejection or an error.

                  How to verify

                  uv run pytest tests/test_admission.py tests/test_planner_loop.py -q
                  uv run pytest -q
                  uv run ruff check .

                  Follow the adversarial style already in tests/test_admission.py: renaming a denied kind does not evade the policy, nor does hiding the rename in a nested scope, and every failed check is reported rather than just the first. New tests should include a traversing path in args being refused, and a caller that lies about parent_depth gaining nothing.

                  Acceptance criteria

                  • A proposal whose arguments violate a declared schema or rule is refused, with a reason code and remedy in feedback()
                  • Nothing executes during a check — no factory call, no meter write
                  • Every failed check is still reported, not just the first
                  • The refusal appears on the admission trace event and does not inflate node-execution counts
                  • Materialisation still binds to the authorisation by fingerprint
                  • Depth cannot be understated by the caller (if you take that half)
                  • The README paragraphs stating these two limits are updated
                  • uv run pytest green, uv run ruff check . clean

                  Skill level — experience required

                  This is the security core of the project, and the existing tests are adversarial on purpose — they assume someone is trying to get a denied capability past the gate. You need to understand why every decision keys on kind and never on name, why a proposal cannot carry code, and why a rejection is data rather than a downgraded approval, before you change what is checked. Please propose your design in a comment first; a check that can be bypassed is worse than a documented gap, because the gap is at least honest.

                  Activity

                  Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    architectureChanges a subsystem boundary or a cross-cutting contractenhancementNew feature or requestexperience requiredDeep familiarity with the codebase or domain needed; not a starter taskhelp wantedExtra attention is needed

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      planner: the admission gate authorises a kind, not its arguments #10

                      Description

                      @Shashankss1205

                      Summary

                      Two related gaps in the admission gate, both documented and both deliberate:

                      Arguments are not authorised. No rule reaches ProposedNode.args, so a proposal carrying args={"path": "/etc/passwd"} is admitted on the strength of its kind alone. Materializer drops args by default (forward_args=False); turning that on hands a model's unchecked dictionary to your factory, and gating it becomes the factory's job.

                      parent_depth is the caller's word. The checker cannot see how deep the run actually is, so a caller that always passes 0 has no recursion limit beyond the nesting visible inside a single proposal.

                      Why this matters

                      The admission gate is the part of this project with no prior art to copy, and it is the reason the rest exists: a planner proposes, a deterministic checker admits, and only then does anything execute. Its five checks are strong — kind in the registry, edge permitted between kinds, worst case within the remaining budget, depth within limit, acyclic — and a decision keyed on kind rather than instance name means renaming cannot launder a denied capability. Closing the argument gap is what would let graph engineering claim that an admitted proposal is safe to run rather than merely well-shaped, which is the difference between a gate and a shape check.

                      Where in the code

                      • grapharc/planner/admission.pyAdmissionChecker.check and the five checks; :775_find_cycle; :271 the tier-ordering note
                      • grapharc/planner/proposal.pyProposedNode, Subgraph, _NAME (note extra="forbid", so a proposal cannot carry code)
                      • grapharc/planner/materialize.pyMaterializer, forward_args, and the fingerprint match that binds materialisation to the authorisation
                      • grapharc/planner/loop.py — where parent_depth is passed
                      • grapharc/policy/engine.pycheck_node, if argument rules belong in the document

                      What to change

                      Two separable pieces; either is a valid PR.

                      Argument authorisation. The design question is where the schema comes from.

                      • A NodeSpec could declare an args schema (a Pydantic model) that the checker validates a proposal's args against. This keeps the registry as the source of truth, which matches the existing rule that costs come from the registry, never the proposal.
                      • Or argument rules live in the policy TOML, next to the node and edge rules.
                      • Whichever you choose: a rejection must remain data. AdmissionResult.feedback() hands the per-check list with codes and remedies back to the planner as its next round's input, and the loop never retries an identical proposal. A new check must produce a reason code and a remedy in that same shape, and must appear in the admission trace event's failed-check list.
                      • Nothing may run during a check: NodeSpec.factory is never called and the budget meter is read, not written. Validation must not violate that.
                      • Decide what happens to forward_args=False. If args are now authorised, is forwarding them still off by default?

                      Real depth. Give the checker a trustworthy depth rather than a caller-supplied integer — most likely by threading it through RunContext or the loop's own state, so a caller cannot understate it. Then decide whether an understated depth is a rejection or an error.

                      How to verify

                      uv run pytest tests/test_admission.py tests/test_planner_loop.py -q
                      uv run pytest -q
                      uv run ruff check .

                      Follow the adversarial style already in tests/test_admission.py: renaming a denied kind does not evade the policy, nor does hiding the rename in a nested scope, and every failed check is reported rather than just the first. New tests should include a traversing path in args being refused, and a caller that lies about parent_depth gaining nothing.

                      Acceptance criteria

                      • A proposal whose arguments violate a declared schema or rule is refused, with a reason code and remedy in feedback()
                      • Nothing executes during a check — no factory call, no meter write
                      • Every failed check is still reported, not just the first
                      • The refusal appears on the admission trace event and does not inflate node-execution counts
                      • Materialisation still binds to the authorisation by fingerprint
                      • Depth cannot be understated by the caller (if you take that half)
                      • The README paragraphs stating these two limits are updated
                      • uv run pytest green, uv run ruff check . clean

                      Skill level — experience required

                      This is the security core of the project, and the existing tests are adversarial on purpose — they assume someone is trying to get a denied capability past the gate. You need to understand why every decision keys on kind and never on name, why a proposal cannot carry code, and why a rejection is data rather than a downgraded approval, before you change what is checked. Please propose your design in a comment first; a check that can be bypassed is worse than a documented gap, because the gap is at least honest.

                      Activity

                      Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        architectureChanges a subsystem boundary or a cross-cutting contractenhancementNew feature or requestexperience requiredDeep familiarity with the codebase or domain needed; not a starter taskhelp wantedExtra attention is needed

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          planner: the admission gate authorises a kind, not its arguments #10

                          Description

                          @Shashankss1205

                          Summary

                          Two related gaps in the admission gate, both documented and both deliberate:

                          Arguments are not authorised. No rule reaches ProposedNode.args, so a proposal carrying args={"path": "/etc/passwd"} is admitted on the strength of its kind alone. Materializer drops args by default (forward_args=False); turning that on hands a model's unchecked dictionary to your factory, and gating it becomes the factory's job.

                          parent_depth is the caller's word. The checker cannot see how deep the run actually is, so a caller that always passes 0 has no recursion limit beyond the nesting visible inside a single proposal.

                          Why this matters

                          The admission gate is the part of this project with no prior art to copy, and it is the reason the rest exists: a planner proposes, a deterministic checker admits, and only then does anything execute. Its five checks are strong — kind in the registry, edge permitted between kinds, worst case within the remaining budget, depth within limit, acyclic — and a decision keyed on kind rather than instance name means renaming cannot launder a denied capability. Closing the argument gap is what would let graph engineering claim that an admitted proposal is safe to run rather than merely well-shaped, which is the difference between a gate and a shape check.

                          Where in the code

                          • grapharc/planner/admission.pyAdmissionChecker.check and the five checks; :775_find_cycle; :271 the tier-ordering note
                          • grapharc/planner/proposal.pyProposedNode, Subgraph, _NAME (note extra="forbid", so a proposal cannot carry code)
                          • grapharc/planner/materialize.pyMaterializer, forward_args, and the fingerprint match that binds materialisation to the authorisation
                          • grapharc/planner/loop.py — where parent_depth is passed
                          • grapharc/policy/engine.pycheck_node, if argument rules belong in the document

                          What to change

                          Two separable pieces; either is a valid PR.

                          Argument authorisation. The design question is where the schema comes from.

                          • A NodeSpec could declare an args schema (a Pydantic model) that the checker validates a proposal's args against. This keeps the registry as the source of truth, which matches the existing rule that costs come from the registry, never the proposal.
                          • Or argument rules live in the policy TOML, next to the node and edge rules.
                          • Whichever you choose: a rejection must remain data. AdmissionResult.feedback() hands the per-check list with codes and remedies back to the planner as its next round's input, and the loop never retries an identical proposal. A new check must produce a reason code and a remedy in that same shape, and must appear in the admission trace event's failed-check list.
                          • Nothing may run during a check: NodeSpec.factory is never called and the budget meter is read, not written. Validation must not violate that.
                          • Decide what happens to forward_args=False. If args are now authorised, is forwarding them still off by default?

                          Real depth. Give the checker a trustworthy depth rather than a caller-supplied integer — most likely by threading it through RunContext or the loop's own state, so a caller cannot understate it. Then decide whether an understated depth is a rejection or an error.

                          How to verify

                          uv run pytest tests/test_admission.py tests/test_planner_loop.py -q
                          uv run pytest -q
                          uv run ruff check .

                          Follow the adversarial style already in tests/test_admission.py: renaming a denied kind does not evade the policy, nor does hiding the rename in a nested scope, and every failed check is reported rather than just the first. New tests should include a traversing path in args being refused, and a caller that lies about parent_depth gaining nothing.

                          Acceptance criteria

                          • A proposal whose arguments violate a declared schema or rule is refused, with a reason code and remedy in feedback()
                          • Nothing executes during a check — no factory call, no meter write
                          • Every failed check is still reported, not just the first
                          • The refusal appears on the admission trace event and does not inflate node-execution counts
                          • Materialisation still binds to the authorisation by fingerprint
                          • Depth cannot be understated by the caller (if you take that half)
                          • The README paragraphs stating these two limits are updated
                          • uv run pytest green, uv run ruff check . clean

                          Skill level — experience required

                          This is the security core of the project, and the existing tests are adversarial on purpose — they assume someone is trying to get a denied capability past the gate. You need to understand why every decision keys on kind and never on name, why a proposal cannot carry code, and why a rejection is data rather than a downgraded approval, before you change what is checked. Please propose your design in a comment first; a check that can be bypassed is worse than a documented gap, because the gap is at least honest.

                          Activity

                          Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            architectureChanges a subsystem boundary or a cross-cutting contractenhancementNew feature or requestexperience requiredDeep familiarity with the codebase or domain needed; not a starter taskhelp wantedExtra attention is needed

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              planner: the admission gate authorises a kind, not its arguments #10

                              Description

                              @Shashankss1205

                              Summary

                              Two related gaps in the admission gate, both documented and both deliberate:

                              Arguments are not authorised. No rule reaches ProposedNode.args, so a proposal carrying args={"path": "/etc/passwd"} is admitted on the strength of its kind alone. Materializer drops args by default (forward_args=False); turning that on hands a model's unchecked dictionary to your factory, and gating it becomes the factory's job.

                              parent_depth is the caller's word. The checker cannot see how deep the run actually is, so a caller that always passes 0 has no recursion limit beyond the nesting visible inside a single proposal.

                              Why this matters

                              The admission gate is the part of this project with no prior art to copy, and it is the reason the rest exists: a planner proposes, a deterministic checker admits, and only then does anything execute. Its five checks are strong — kind in the registry, edge permitted between kinds, worst case within the remaining budget, depth within limit, acyclic — and a decision keyed on kind rather than instance name means renaming cannot launder a denied capability. Closing the argument gap is what would let graph engineering claim that an admitted proposal is safe to run rather than merely well-shaped, which is the difference between a gate and a shape check.

                              Where in the code

                              • grapharc/planner/admission.pyAdmissionChecker.check and the five checks; :775_find_cycle; :271 the tier-ordering note
                              • grapharc/planner/proposal.pyProposedNode, Subgraph, _NAME (note extra="forbid", so a proposal cannot carry code)
                              • grapharc/planner/materialize.pyMaterializer, forward_args, and the fingerprint match that binds materialisation to the authorisation
                              • grapharc/planner/loop.py — where parent_depth is passed
                              • grapharc/policy/engine.pycheck_node, if argument rules belong in the document

                              What to change

                              Two separable pieces; either is a valid PR.

                              Argument authorisation. The design question is where the schema comes from.

                              • A NodeSpec could declare an args schema (a Pydantic model) that the checker validates a proposal's args against. This keeps the registry as the source of truth, which matches the existing rule that costs come from the registry, never the proposal.
                              • Or argument rules live in the policy TOML, next to the node and edge rules.
                              • Whichever you choose: a rejection must remain data. AdmissionResult.feedback() hands the per-check list with codes and remedies back to the planner as its next round's input, and the loop never retries an identical proposal. A new check must produce a reason code and a remedy in that same shape, and must appear in the admission trace event's failed-check list.
                              • Nothing may run during a check: NodeSpec.factory is never called and the budget meter is read, not written. Validation must not violate that.
                              • Decide what happens to forward_args=False. If args are now authorised, is forwarding them still off by default?

                              Real depth. Give the checker a trustworthy depth rather than a caller-supplied integer — most likely by threading it through RunContext or the loop's own state, so a caller cannot understate it. Then decide whether an understated depth is a rejection or an error.

                              How to verify

                              uv run pytest tests/test_admission.py tests/test_planner_loop.py -q
                              uv run pytest -q
                              uv run ruff check .

                              Follow the adversarial style already in tests/test_admission.py: renaming a denied kind does not evade the policy, nor does hiding the rename in a nested scope, and every failed check is reported rather than just the first. New tests should include a traversing path in args being refused, and a caller that lies about parent_depth gaining nothing.

                              Acceptance criteria

                              • A proposal whose arguments violate a declared schema or rule is refused, with a reason code and remedy in feedback()
                              • Nothing executes during a check — no factory call, no meter write
                              • Every failed check is still reported, not just the first
                              • The refusal appears on the admission trace event and does not inflate node-execution counts
                              • Materialisation still binds to the authorisation by fingerprint
                              • Depth cannot be understated by the caller (if you take that half)
                              • The README paragraphs stating these two limits are updated
                              • uv run pytest green, uv run ruff check . clean

                              Skill level — experience required

                              This is the security core of the project, and the existing tests are adversarial on purpose — they assume someone is trying to get a denied capability past the gate. You need to understand why every decision keys on kind and never on name, why a proposal cannot carry code, and why a rejection is data rather than a downgraded approval, before you change what is checked. Please propose your design in a comment first; a check that can be bypassed is worse than a documented gap, because the gap is at least honest.

                              Activity

                              Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                architectureChanges a subsystem boundary or a cross-cutting contractenhancementNew feature or requestexperience requiredDeep familiarity with the codebase or domain needed; not a starter taskhelp wantedExtra attention is needed

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions